Skip to content

agy parity T7 — agy workers can't resume, be nudged, or auto-resume: implement splice_prompt/2 - #1966

Merged
ryanrborn merged 12 commits into
mainfrom
feature/1813-agy-parity-t7-agy-workers-can
Sep 23, 2026
Merged

ryanrborn merged 12 commits into
mainfrom
feature/1813-agy-parity-t7-agy-workers-can

Conversation

@ryanrborn

Copy link
Copy Markdown
Owner

Summary

Follow-up fix to the earlier bd-b7e33c AC5 post-merge work. Coordinator
verification on 2026-09-19 (~05:32Z, on a real agy resume, bd-937r5u) found
the picture had split: the provider-preservation half was confirmed fixed
(a resumed agy task stayed on gemini-3.1-pro-high instead of silently
falling back to Claude), but conversation continuity itself was still
failing — the resumed run did not reuse the original --conversation id;
it created new conversation databases instead.

Root cause: Dispatch.resume_session/2 derived the provider it pins onto
:agent_type and the session_id it threads into --resume/--conversation
from two independent "latest usage-ledger row" queries
(latest_provider/1 and latest_session_id/1). On a task with more than one
usage row — e.g. an earlier successful agy session plus a later attempt on a
different provider that errored before a session_id was ever captured — the
two queries can pick different rows. That lets :agent_type get pinned to
one row's provider while --conversation gets threaded with a different
row's session id, which either dispatches the wrong CLI entirely or hands
that CLI a conversation id it has no way to honor. This was flagged as a
non-blocking observation in the prior review of this ticket and is exactly
the shape the coordinator's live observation surfaced.

Fix: latest_session_id/1 now returns the provider from the same usage
row the session_id came from, and resume_session/2 pins :agent_type to
that value instead of a separate latest_provider/1 lookup. The two values
can no longer diverge by construction.

Round 2: the ReviewGate found the branch stale/conflicted against
origin/mainorigin/main had independently landed PR #1926 (bd-2exkl0),
which generalized resume-provider inheritance via resolve_resume_provider/2
(authoring-run provider + availability fallback + coordinator escalation)
and touched the exact same lines in Dispatch.resume/2 / resume_session/2
this branch's AC5 fix touched. Merged origin/main and resolved the
conflict by keeping resolve_resume_provider/2 as-is for resume/2 (no
session-id constraint, so the general authoring-provider/fallback resolution
is correct there), and adding a session-aware
resolve_session_resume_provider/3 for resume_session/2 that still
prefers the provider recorded on the exact usage-ledger row the session_id
came from — falling back to the general resolution only when the caller
forces a provider or that row's provider is unavailable. This preserves the
AC5 mismatch fix (and its regression test) on top of the newer, more general
provider-fallback machinery from main.

Round 2 review fixes (7a1e9a1): the mismatch fallback dropped
resume_session_id but never built resume/2's promised git-derived
briefing, so a resumed dispatch on a mismatched provider silently carried
the original task prompt with no continuity. resume_session/2 now calls
ResumeContext.build/3 (same as resume/2) on that path. Separately,
inlining the provider == session_provider branch directly into
resume_session/2 (instead of dispatching through a narrow private helper)
eliminated a dialyzer pattern_match false positive that had required a
file-wide .dialyzer_ignore.exs suppression — the suppression was removed.

Round 3 review fixes (8c575b1): the guard added to fix that dialyzer
false positive (is_atom(session_provider) and ...) was itself a tautology
safe_provider_atom/1 never returns anything but an atom or nil — which
made dialyzer flag the andalso's false arm as dead code. Swapped to
not is_nil(session_provider) and ..., which is not a tautology, keeps the
nil-rejection explicit, and needs no suppression. Also fixed a misleading
log: the mismatch-path Logger.info used to fire before
ResumeContext.build/3 ran and always claimed a briefing was attached, even
on the {:error, _} arm that proceeds with none — it now logs after the
case, split into Logger.info when a real briefing was built and
Logger.warning (naming the failure reason) when it wasn't.

Test plan

  • New test resume_session/2 pins the provider to the SAME row the session_id came from (dispatch_test.exs) — reproduces the mismatch
    directly (a newer no-session-id "claude" row vs. an older "gemini"
    row carrying the real session_id), fails against the pre-fix code
    (times out waiting for the agy stub, since resume dispatched Claude
    instead) and passes after the fix, asserting the exact --conversation agy-conv-mismatch value reaches the spawned argv.

  • Tightened regression test asserts the ResumeContext.build/3 briefing
    text ("RESUMING work on task") is actually present in the resumed
    argv on the mismatch path, not just the absence of --resume.

  • mix test test/arbiter/worker/dispatch_test.exs test/arbiter/agents/gemini_test.exs test/arbiter/worker_resume_test.exs test/arbiter/worker/respawn_provider_test.exs — 234 tests, 0 failures (re-run on HEAD 8c575b1c).

  • mix dialyzer --format short (umbrella root, MIX_ENV=dev, matching the CI audit job) — Total errors: 61, Skipped: 61, Unnecessary Skips: 0, exit 0. No .dialyzer_ignore.exs suppression re-added.

  • mix precommit (format, compile --warnings-as-errors, full test suite) — 0 failures at round 2; re-validated at round 3 via the targeted suite above plus the umbrella dialyzer run.

  • Post-merge, coordinator-owned (verify_after_deploy, not a merge gate): re-run the same live check that surfaced this — arb worker resume <task> against a real agy/gemini task with a resumable prior session. A working result looks like: the resumed worker stays on gemini-... (not Sonnet), AND the agy process's .gemini/antigravity-cli/conversations/<original-uuid>.db file (not a fresh one) keeps growing after the resume — i.e. the SAME conversation database from before the resume is the one that gets appended to, not a newly created one.

References

bd-b7e33c

Closes #1813

🤖 Generated with Claude Code

ryanrborn and others added 10 commits September 17, 2026 08:38
gemini/agy was the one provider Worker.inject_resume_argv/4 and
inject_nudge_argv/3 could not rewrite at all — no splice_prompt/2 meant
every resume attempt and gate nudge fell straight to :unsupported_provider
and parked, even though the underlying agy CLI supports `--conversation
<id>` for session resume.

splice_prompt/2 finds the "-p" prompt slot that default_argv/2 always
produces (both the agy and upstream-gemini branches share that shape) and:
  - for a `["--resume", sid, prompt]` insert: on the agy branch, swaps in
    the new prompt and inserts `--conversation <id>` right after it,
    leaving --print-timeout/--model/--effort and everything else
    untouched; on the upstream-gemini branch (no --conversation support)
    returns {:error, :resume_unsupported} instead of a bogus invocation
  - for a `[nudge]` insert: swaps only the prompt on either branch
  - returns {:error, :no_print_slot} when there's no "-p" flag (fixtures)

Worker.inject_resume_argv/4 and inject_nudge_argv/3 already dynamically
dispatch to splice_prompt/2 via function_exported?/3, so no worker.ex
change was needed there beyond refreshing a stale comment. Updated the
respawn_provider_test.exs fixture test that asserted the old
:unsupported_provider park behavior for gemini.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gy task's provider to Claude

Post-merge verification of T7 found that both worker_resume (MCP tool,
Dispatch.resume/2) and arb worker resume (Dispatch.resume_session/2)
re-derived the provider from Routing.choose/2 on every resume with no
memory of what the prior run actually used. An agy/gemini task's resume
silently dispatched on Claude instead — spending the quota the operator
routed to agy specifically to conserve, with no signal in the result
that a provider switch happened. For resume_session/2 this was worse
than a wrong choice of agent: a Claude spawn could receive an agy
conversation UUID as --resume, a nonsensical invocation.

Both functions now default :agent_type (unless the caller passes one
explicitly) to the provider recorded on the task's most recent
usage-ledger row, via a new Dispatch.latest_provider/1. A resume stays
on the same provider by construction instead of by accident.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ty-t7-agy-workers-can

# Conflicts:
#	apps/arbiter/test/arbiter/agents/gemini_test.exs
…er and session_id from different usage rows

verification found the provider-preservation half of the earlier fix worked
but conversation continuity did not: latest_provider/1 and latest_session_id/1
ran independent "newest row" queries, so a task whose most recent usage row
recorded a different provider than the row holding the resumable session_id
could pin :agent_type off one row while threading the OTHER row's
conversation id into --conversation. latest_session_id/1 now returns the
provider from the SAME row the session_id came from, and resume_session/2
pins :agent_type to that value instead of a separate lookup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ty-t7-agy-workers-can

Resolves conflicts in dispatch.ex/dispatch_test.exs: origin/main's
resolve_resume_provider/2 (bd-2exkl0, PR #1926) generalized provider
inheritance for resume/2 using the authoring run's provider with
availability fallback + coordinator escalation. Keeps that for resume/2,
and adds resolve_session_resume_provider/3 for resume_session/2 so the
session-id/provider pairing fix from this branch's AC5 post-merge commits
(04b8426, a8abf4d) survives: it still prefers the provider recorded on
the exact usage-ledger row the session_id came from before falling back
to the general authoring-provider resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e the regression test actually regress

Finding 1: the mismatch regression test constructed the mismatch out of two
usage-ledger rows, but the provider resolver reads worker_run rows first
(Run.latest_authoring_provider/1) and only falls back to the usage ledger
when no run carries a provider — so the fixture's extra usage row never
influenced anything and the test passed even with the AC5 fix reverted.
Rebuilt the mismatch as a newer failed Run row (what the resolver actually
reads), stubbed claude on the test PATH so a future regression spawns the
stub instead of the operator's real CLI, and corrected the comment's claim
about which query the resolver uses. Verified: fails pre-fix (times out
waiting for the agy argv file because dispatch goes to the claude stub
instead), passes post-fix.

Finding 2: resolve_session_resume_provider/3 can fall through to a
DIFFERENT provider than the one that captured session_id (unknown/
unavailable session provider, or an explicit agent_type override), but
resume_session/2 still threaded that foreign session_id straight into
resume_opts regardless — producing a bogus invocation like `claude --resume
<agy-conversation-uuid>` on the mismatched CLI. resume_opts now only carries
:resume_session_id when the resolved provider matches the provider that
owns it; otherwise it degrades to resume/2's context-based briefing instead
of handing a foreign conversation id to another CLI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e_session_id/3

CI's mix audit failed dialyzer with `lib/arbiter/worker/dispatch.ex:1:pattern_match`:
the `false` clause of the private, single-call-site
maybe_put_resume_session_id(opts, provider == session_provider, session_id)
is flagged unreachable because dialyzer's success typing narrows the boolean
argument to the literal `true`. It's a known success-typing precision limit,
not a real dead branch — resume_session/2's explicit agent_type override and
the provider-unavailable fallback in resolve_session_resume_provider/3 both
legitimately produce a provider that differs from session_provider at
runtime, and skipping the :resume_session_id put in that case is the entire
point of the guard added in 9b5ec6e.

Added a regression test exercising the override path directly (resume with
an explicit agent_type that differs from the provider that captured the
prior session_id) to prove the `false` clause is reachable and correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n-provider mismatch, drop the file-wide dialyzer suppression it required

Finding 1: resume_session/2's provider-mismatch fallback dropped
resume_session_id but never built resume/2's git-derived briefing it claimed
to degrade to, so a resumed dispatch on a mismatched provider silently
carried the original task prompt with no continuity — risking redone work.
It now calls ResumeContext.build/3 (same as resume/2) and logs the dropped
session id, or Logger.info-only degrades to no briefing if the worktree
briefing itself can't be built.

Finding 2: inlining the provider == session_provider branch directly into
resume_session/2 (per reviewer's suggested restructure) instead of
dispatching through a narrow two-clause private helper eliminates the
dialyzer pattern_match false positive that needed the file-wide
lib/arbiter/worker/dispatch.ex suppression; confirmed clean with
mix dialyzer --format short after removing the ignore entry.

Tightened the round-1 mismatch test to assert the briefing text is present
in the resumed argv, not just the absence of --resume.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…efing outcome

Finding 1 (merge blocker): resolve_session_resume_provider/3's is_atom/1
guard on session_provider was a tautology (safe_provider_atom/1 never
returns anything but an atom or nil), making dialyzer's false-arm of the
andalso genuinely dead code. Swap to not is_nil/1, which is not a
tautology and keeps the nil-rejection explicit without re-adding the
removed .dialyzer_ignore.exs suppression.

Finding 2 (nit): the mismatch branch's Logger.info fired before
ResumeContext.build/3 and always claimed a briefing was attached, even
on the {:error, _} arm that proceeds with none. Move the log after the
case and split it: Logger.info on the real briefing, Logger.warning
(naming the build failure reason) when none was built.
@ryanrborn
ryanrborn marked this pull request as draft September 22, 2026 15:12
…r-runs history

The 2026-09-22 04:26Z post-merge verification of AC5 misread an agy run as
having `session_id: NULL` / `provider: null`, and concluded the conversation
id was never captured. It was: `worker_runs.session_id` and `.provider` were
correctly populated in the DB for both the original and resumed run (and
`resumed_from_run_id` was correctly set on the resumed one) — but
`arb worker runs --json` (backed by `RunJSON.summary/1`) and the
`worker_runs` MCP tool (`serialize_worker_run_summary/1`) both silently
dropped `session_id`/`resumed_from_run_id` from their output, so there was no
way to observe them without querying the DB directly.

Surface all three fields on both surfaces so resume continuity (or its
absence) is directly observable from `arb worker runs <task> --json` and the
`worker_runs` MCP tool, without DB access.

The verified live defect was that the run was resumed via the `worker_resume`
MCP tool, which calls `Dispatch.resume/2` — documented and signed off
(2026-06-05) as the deliberately fresh-agent, git-briefing resume path that
carries no session-resume id for any provider. `Dispatch.resume_session/2`
(backing `arb worker resume` / `POST /api/workers/:task_id/resume`) is the
session-continuing path, and it already threads agy's conversation id
correctly per the existing dispatch_test.exs coverage
("resume_session/2 pins the provider to the SAME row the session_id came
from"). Flagged back to the coordinator for a scope decision on whether
`worker_resume`/`resume/2` should also gain opportunistic session
continuity — that would be a cross-provider behavior change reversing a
prior sign-off, out of this fix's authority to make unilaterally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.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.

agy parity T7 — agy workers can't resume, be nudged, or auto-resume: implement splice_prompt/2

1 participant