Skip to content

fix(agent): complete saved results after supervisor gap adjudication - #31

Merged
William Zhang (phytal) merged 5 commits into
mainfrom
fix/adjudicated-task-completion
Sep 5, 2026
Merged

William Zhang (phytal) merged 5 commits into
mainfrom
fix/adjudicated-task-completion

Conversation

@phytal

@phytal William Zhang (phytal) commented Sep 5, 2026

Copy link
Copy Markdown

Problem

No-Ticket: Production Zero regression investigated from nebula-mono PR PrimeIntellect-ai#170 traces; no separate Research ticket was created.

The archived Zero review of nebula-mono PR PrimeIntellect-ai#170 shows an otherwise finished child repeatedly failing completion after the parent accepted a missing Docker E2E check as nonblocking. The parent replaced that owner, and the superseded session continued attempting task actions.

Changes

  • Add the opt-in adjudicatedTaskCompletion policy capability. Save a host-valid proposed result against its current attempt while gaps await supervision.
  • Preserve gap IDs and record the adjudicating supervisor, disposition, next action, and any revalidation error. Keep existing resolved/declined records readable: declined remediation is an accepted limitation, not a claim that verification ran.
  • Revalidate and atomically finish the saved result when all its gaps are accepted. Resolved gaps requiring analysis resume the existing owner instead. Invalid coverage remains rejected.
  • Preserve proposals through recovery, clear them on replacement/cancellation/new gaps, and fence completion against the current attempt. Identical completion/decision retries are idempotent.
  • Abort hosted superseded task sessions at the durable ownership transition and dispose their runtime. Preserve their semantic handoff and rejected hypotheses.
  • Fence concurrent session navigation during retirement: dispose the captured session, reject late replacement publication, and release abandoned replacement leases. Includes a deterministic build-versus-retirement race regression.
  • Dispose constructed child sessions if ownership binding rejects a retired attempt; fence publication during parent/child disposal, including extension binding. Cleanup preserves original errors and releases leases even if session disposal fails.
  • Revalidate fresh-child task eligibility before publication so cancellation/interruption/completion during construction cannot publish a terminal replacement. Retained completed-session hydration remains supported.

No token ceilings or compaction-policy changes. This addresses the observed completion tail, not all of PR PrimeIntellect-ai#170's exploration cost.

Verification

  • npm run check passed, including installer/browser checks and Python inspection checks (one platform skip).
  • 71 tests passed across task-graph, task-gap-completion, task-attempt-runtime, and agent-session-runtime-model-fallback. An additional runtime/lease validation pass covered 47 tests across five files (overlaps with the 71-test suite).
  • Zero integration against the locally compiled candidate: 24 compatibility/integration tests passed, one Linux-only skip. Includes a PR Add session and RLM heartbeats PrimeIntellect-ai/prime-agent#170-shaped gap/acceptance/publication regression and required-coverage rejection.

Rollout

Includes the lockstep v0.9.4 source/lockfile version bump. Merging triggers the standard stable release workflow. Zero's companion draft must then pin that checksum-verified release before it can merge or deploy. No production changes were made.

@zeroset-agent zeroset-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero Review - 1 finding shown inline.

8,088,836 tokens used (8,030,172 prompt / 58,664 completion; 6,560,384 served from cache, billed ~10%).

Models - prime-root: zero/swe-root (1 agent, 1 task, 40 calls, 2,280,768 tokens), prime-child: zero/balanced (5 agents, 3 tasks, 106 calls, 5,808,068 tokens)

const signal = session.taskGraph.getAttemptSignal(session.taskId, session.taskActorId);
const retire = () => {
session.requestAbort();
void this.dispose().catch((error: unknown) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Retirement can dispose the replacement session instead of the superseded one

The task-attempt abort callback captures the superseded session for requestAbort(), but then calls this.dispose(). disposeOnce() awaits shutdown work before evaluating this.session.disposeAsync(). During that await, a concurrent switch/new/fork/resume flow can apply a replacement to this._session, so retirement disposes the replacement session instead of the captured superseded session. This can tear down the active replacement while leaving the old session undisposed. Bind disposal to the captured session or serialize retirement with replacement.

Evidence

  • The new callback captures the old session only for requestAbort() and then invokes runtime-level dispose().
  • disposeOnce() awaits shutdown steps before dereferencing mutable this.session for disposal.
  • Replacement flows can assign a new session during that await and no runtime-level serialization prevents this interleaving.

@zeroset-agent

zeroset-agent Bot commented Sep 5, 2026

Copy link
Copy Markdown

Zero Review

Summary

  • 0 critical, 1 warnings, 0 nits
  • 6,823,968 tokens used (6,755,519 prompt / 68,449 completion; 5,219,072 served from cache, billed ~10%)
  • Models - prime-root: zero/swe-root (1 agent, 1 task, 33 calls, 1,347,693 tokens), prime-child: zero/balanced (6 agents, 5 tasks, 138 calls, 5,476,275 tokens)
  • Sandbox review mode: full checkout and tool-backed review executed.

Findings

🟡 Preserve blocked state for a root with a saved completion during recovery (packages/coding-agent/src/core/task-graph.ts:1684)
When a graph is reopened while its root task is blocked with pendingCompletion, this recovery status calculation restores the root as running unless it also has a pending resume request. Later, resolveGap() only treats the task as newly runnable when its current status is still blocked. As a result, accepting/declining the final gap after restart skips the saved-result adjudication path: the proposal is not revalidated or published, and the root remains running without an owner to finish it. This recreates the stuck completion tail for root-owned work across host recovery. Keep such roots blocked until their gaps are adjudicated, or make the resolution path explicitly recognize the recovered pending-completion state, and add a root recovery regression test.

Evidence

  • The new recovery calculation only preserves pending for a pending resume request and otherwise forces the root to running, including roots that still have a saved completion proposal.
  • The new gap-resolution completion path is gated by the task still being blocked; the focused task-graph and gap-completion suites passed but do not cover this root recovery sequence.

@zeroset-agent zeroset-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero Review - 1 finding shown inline.

5,037,713 tokens used (4,991,526 prompt / 46,187 completion; 3,668,608 served from cache, billed ~10%).

Models - prime-root: zero/swe-root (1 agent, 1 task, 29 calls, 1,132,706 tokens), prime-child: zero/balanced (3 agents, 2 tasks, 85 calls, 3,905,007 tokens)

const session = this._session;
if (!session.taskGraph || !session.taskId || !session.taskActorId || this._metadata.kind !== "subagent") return;
const task = session.taskGraph.getTask(session.taskId);
if (["completed", "cancelled", "interrupted"].includes(task.status)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fence subagent startup against attempt retirement

The new attempt-abort callback starts disposal, but subagent runtime construction is asynchronous. If reassignment happens while createRlmSubagentRuntime() awaits construction, disposal can snapshot and clear the current child map before the late runtime is inserted and published. That hosted session and lease then escape disposal and may continue after ownership changed. The bind path also only installs an abort listener, so an attempt already aborted before binding is missed because AbortSignal does not replay the event. Check signal.aborted, fence publication against disposing, and dispose/release late construction results or track pending creations during disposal.

Evidence

  • The changed listener starts abort and disposal on reassignment, while child construction is awaited before the runtime is registered.
  • Disposal snapshots registered children; there is no post-await disposal fence or immediate already-aborted check before publication.

@zeroset-agent zeroset-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero Review - 1 finding shown inline.

5,307,709 tokens used (5,255,766 prompt / 51,943 completion; 4,323,200 served from cache, billed ~10%).

Models - prime-root: zero/swe-root (1 agent, 1 task, 40 calls, 2,323,608 tokens), prime-child: zero/balanced (2 agents, 2 tasks, 71 calls, 2,984,101 tokens)

if (!session.taskGraph || !session.taskId || !session.taskActorId || this._metadata.kind !== "subagent") return;
const task = session.taskGraph.getTask(session.taskId);
if (["completed", "cancelled", "interrupted"].includes(task.status)) return;
const signal = session.taskGraph.getAttemptSignal(session.taskId, session.taskActorId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fence attempts that retired before runtime binding

bindRuntimeHost() registers an abort listener but does not handle an attempt signal that is already aborted. If reassignment happens while createAgentSessionRuntime() is still awaiting, the abort event fires before this listener is added. The newly constructed runtime can then be inserted and published for the superseded task actor because the publication path does not recheck ownership. Check signal.aborted or current ownership immediately after construction and again before publication; dispose the stale runtime and reject the bind.

Evidence

  • AbortSignal listeners added after abort() do not receive the earlier abort event.
  • The post-construction publication path inserts the runtime and invokes onSessionPublished without a current-owner check.

Related locations

  • packages/coding-agent/src/core/agent-session-runtime.ts:400

@phytal
William Zhang (phytal) merged commit 3363dc9 into main Sep 5, 2026
17 checks passed
@phytal
William Zhang (phytal) deleted the fix/adjudicated-task-completion branch September 5, 2026 03:14
@phytal

Copy link
Copy Markdown
Author

Merged as 3363dc9 for the v0.9.4 release.

Addressed retirement cleanup and fresh-child startup races, including terminal tasks during construction/binding. The reported missed-listener reassignment path is fenced by synchronous current-owner validation; fresh publication also checks active task status. Completed-session hydration remains supported.

Full CI and npm run check passed. Targeted lifecycle suite: 71 passed; runtime/lease suite: 47 passed (overlapping tests). Zero PrimeIntellect-ai#209 will pin the released artifact before deployment.

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.

1 participant