Skip to content

agy-proxy: stream resume-retry judges 'no output' before flushing the final line — an unterminated partial delta is silently retried as a fresh conversation (PR #679) #683

Closed

Description

@dezren39

Finding

PR #679 (fixes #678, head f935ce0e) judges a failed resume's "no output" condition inconsistently between the two modes of bin/agy-proxy.mjs, because the stream close handler checks hasOutput before flushing the final unterminated line, while the non-stream handler parses the whole stdout buffer before its retry gate:

  • Stream (bin/agy-proxy.mjs on PR head): child.on('close', (code, signal) => …) runs the resume-retry check first — if (code !== 0 && !hasOutput && allowRetry && targetConvId) at :471 — and only afterwards flushes the tail line (if (lineBuffer.trim()) { processJsonLine(lineBuffer.trim()); } at :479-481) and the new exit-code failure check (:489). A step_update text delta that arrives as the child's final stdout bytes without a trailing newline is still sitting in lineBuffer at the retry decision, so hasOutput is false even though the run produced output.
  • Non-stream (same file, PR head): stdoutBuffer is fully parsed into responseText before the if (code !== 0) block (:580), and the retry inside it is gated on !responseText — so the same run shape is correctly treated as "produced output": no retry, HTTP 502.

Consequence in stream mode: a resumed conversation whose run emits a partial delta in an unterminated final line and then exits non-zero is silently retried as a fresh conversation, and the client receives the fresh run's answer with finish_reason: "stop" and the resumed conversation's id — the failure is never reported, the partial output is discarded, and a second agy run executes even though the retry's own precondition ("failed without output", #678's acceptance criteria) does not hold. Whether the failure is reported or silently swapped for a fresh-conversation answer depends only on whether agy newline-terminated its last JSON line. The sibling proxies flush first: bin/muse-proxy.mjs and bin/codex-proxy.mjs both process a non-empty lineBuffer as the first step of their close handlers before judging the run.

Note the success path already relies on the flush at :479-481 to deliver an unterminated final line, so unterminated tails are a shape this file explicitly handles — just not at the retry decision.

Evidence (reproduced on PR #679 head f935ce0e1a5e4a2caf1880ee4f1badeec1638865)

Ran the PR-head bin/agy-proxy.mjs with a stub agy at $HOME/.local/bin/agy (the proxy's own binary resolution; the bin/agy-proxy.test.mjs pattern from the PR), requests carrying x-conversation-id: conv-123:

  • Stub, on --conversation: emits {"event":"step_update","step_update":{"text_delta":"partial answer"}} with no trailing newline (non-stream shape: {"response":"partial answer"} likewise unterminated), prints stub agy: run failed midway to stderr, exits 3; a fresh (no-conversation) run succeeds with Hello world:
    • stream → 200, fresh run's Hello world chunk + finish_reason: "stop" + [DONE]; proxy log shows exactly 1 retrying as fresh conversation line — no error event, partial output never sent
    • non-stream → 502 {"error":"agy process exited with non-zero code","code":3,"stderr":"stub agy: run failed midway"} (correct — the control)
  • Control, same stub but the partial line is newline-terminated:
    • stream → partial answer chunk, then data:-framed agy execution failed: stub agy: run failed midway error event + [DONE], no stop chunk (the PR's intended new behaviour)
    • non-stream → 502, same as above

So the two modes disagree on identical run shapes, and stream mode disagrees with itself based on a trailing newline. PR #679's new tests cover the resume-retry case only with no output at all (RETRYME) and the partial-output case only without a conversation (FAILPARTIAL, newline-terminated) — the resume + unterminated-partial shape is untested. This is filed against the PR as a follow-up; fixing it inside PR #679 before merge also satisfies this issue.

Dedup: issue searches for agy-proxy return only #678 (OPEN, the exit-code masking this PR fixes — its acceptance requires the retry be preserved for a failed resume with no output, which is exactly the precondition misjudged here) and unrelated port/discovery issues; searches for lineBuffer / unterminated / resume retry return only #678. Cross-repo: bin/agy-proxy.mjs exists only in this repo (no copy in developing-today/opencode, developing-today/openchamber, dezren39/nix — verified by git grep on each clone's origin/main).

Why it matters

The retry exists to recover from a stale conversation id, not to replace a run that already answered in part. Triggering it on a run that produced output hides a real failure (expired auth, CLI crash) behind an unrelated fresh conversation's answer, presented under the resumed conversation's id as a clean stop — the same silent-substitution failure mode #678 was filed for, reachable only in stream mode (the mode OpenCode actually consumes) and only when the final line is unterminated.

Acceptance criteria

  • The stream close handler flushes any non-empty lineBuffer (via processJsonLine) before the resume-retry decision, so hasOutput reflects all output the child produced — mirroring the non-stream ordering in the same file and the flush-first ordering in bin/muse-proxy.mjs / bin/codex-proxy.mjs.
  • A failed resume (non-zero exit or signal) whose only output is an unterminated final step_update delta is reported as a failure in stream mode — data:-framed error event naming the failure + data: [DONE], no chunk claiming finish_reason: "stop", no fresh-conversation retry — matching the non-stream 502 for the same shape.
  • A failed resume with genuinely no output still retries once as a fresh conversation in both modes (the existing RETRYME behaviour), and successful runs with an unterminated final line still deliver that line before the stop chunk.
  • A new stub case in bin/agy-proxy.test.mjs pins the resume + unterminated-partial + non-zero-exit shape in stream mode (and the non-stream control); ablating the flush-reorder fails exactly that case, and the agy-proxy + muse-proxy + codex-proxy + proxy-port suites pass.

Activity

  1. added
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Oct 25, 2026
  2. dezren39 commented on Oct 26, 2026

    @dezren39
    MemberAuthor

    Intake review

    Still live (verified this run, fleet monitor): bin/agy-proxy.mjs on current main still runs the stream-mode resume-retry check at line 471 (if (code !== 0 && !hasOutput && allowRetry && targetConvId)) before the final-line flush at lines 479–480 (if (lineBuffer.trim()) { processJsonLine(...) }) — the exact ordering the issue describes. The sibling proxies (muse-proxy, codex-proxy) flush first.

    Related: PR #679 / closed #678 (the resume-retry feature and its "failed without output" precondition).

    Notes / risks: this is a correctness bug, not just a test gap: the silent fresh-conversation retry discards partial output and misattributes the new run's answer to the resumed conversation. The fix is an ordering change, but it must preserve the success path's reliance on the same flush.

    Suggested approach: move the lineBuffer flush to the top of the stream close handler (matching the sibling proxies), before the retry decision; add the stub-agy regression case from the issue's evidence (unterminated final delta + non-zero exit must NOT retry).

    Labels normalized by the fleet monitor: kind/bug, priority/normal, effort/s.

  3. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    effort/sSmall: expected under 2 hours of work
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed on Oct 26, 2026
  4. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — triage task 1066 verdict: CONFIRMED on current main (fresh read via gh api contents @main, 2026-10-31). The file was rewritten since filing (resident SessionWorker, PR #1759) but the flaw carried over and is now structural: lineBuffer is only appended/split in _onStdout — no flush exists anywhere; the close handler (:530) calls _fail directly and _fail sets err.hasOutput from newline-terminated deltas only; the retry gate (:955, allowRetry && resumed && !err.hasOutput) then retries as a fresh conversation. Fix follow-up enqueued (flush the tail through the line path before judging; test in bin/agy-proxy.test.mjs). Triage completing as triaged.

  5. added
    queue/queuedHas a queued task in the Supabase agent queue
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    and removed
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 31, 2026
  6. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. added a commit that references this issue on Oct 31, 2026
  8. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)meta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)source/pr-diff-reviewFiled by the pr-diff-review jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions