Skip to content

fix(orchestrator): bound the completion release retry, and publish the sweep bounds that already exist - #379

Merged
miyaontherelay merged 3 commits into
mainfrom
fix/reconcile-wedge-0825
Aug 25, 2026
Merged

fix(orchestrator): bound the completion release retry, and publish the sweep bounds that already exist#379
miyaontherelay merged 3 commits into
mainfrom
fix/reconcile-wedge-0825

Conversation

@miyaontherelay

Copy link
Copy Markdown
Contributor

Two commits against origin/main (46919f2, 0.1.76). Do not merge — the principal owns the merge gate.

Read this first: the brief that requested this PR was wrong about the diagnosis, and one of the two requested fixes would have been actively harmful. The fix that was asked for — a fourth Promise.race deadline on readinessReconcile — is not in this PR, and the reasoning is below. The observability requirement (which was correct, and non-negotiable) and the retry bound (which was correct) both are.


What the evidence actually showed

The report was: heartbeat.readinessReconcile.inFlightMs climbing 1:1 with wall clock (58 s → 208 s → 268 s over ~3.5 min), state: "healthy" throughout, no timeoutMs field, and no pid available to terminate ... repeating 15+ times in one payload. The conclusion drawn was "readinessReconcile is unbounded, and it is not the sweep #374 bounded".

Three of those four inferences do not survive contact with the code.

1. readinessReconcile IS the discovery sweep, and it IS bounded

#reconcileReadyIssues()              factory.ts:2318
  -> #runOnceWithReadinessDeadline() factory.ts:2269   (#296, bounds the WAIT)
    -> runOnce()                     factory.ts:3069
      -> #runOnceWithDiscoveryFence() factory.ts:3115
        -> startDiscoverySweepBudget() sweep-budget.ts  (#374, bounds the SWEEP)

readinessReconcile is the health stanza for the discovery sweep — sweep-budget.ts:17 names it as such in #374's own module doc. Three deadlines are live on that path in 0.1.76: relayfileOperationTimeoutMs per call, readinessReconcileTimeoutMs on the caller's wait, and #374's aggregate sweepBudgetMs.

The pass was bounded. It was bounded at 90 minutesDEFAULT_DISCOVERY_SWEEP_BUDGET_MS = DEFAULT_READINESS_RECONCILE_TIMEOUT_MS = 90 * 60_000 (schema.ts:62,84) — so at 268 s it had 89 minutes left to run, and no published field said so. The absence of timeoutMs from the stanza was read as the absence of a bound from the code. That is a reporting defect, and it is commit 1.

Adding a fourth timer here would have been the exact anti-pattern #374's commit message warns about by name ("bound the whole sweep, not one more call inside it"). Recovery time on this path is sweepBudgetMs, which is already a config dial — no code change gets it.

2. state: "healthy" at 268 s is correct, not a reporting bug

derivedReadinessReconcileState (public-health.ts:478) already re-derives stalled from inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS, and that constant is 10 (public-health.ts:64). The flip was due at 600 s; the observation window (14:37Z–14:41Z) closed 5.5 minutes early. Had the reads continued to 14:47Z the stanza would have said stalled on its own.

Lowering the threshold is the trivially wrong fix, and public-health.ts:53-63 says why: #36 measured a 61-minute post-boot reconcile while the Relayfile mirror hydrated, so a small multiple cries wolf on every cold container. Not changed.

3. missedPasses already existed

On FactoryPublicReadinessReconcileHealth (#295/#300). It was missing from the heartbeat FactoryReadinessReconcileStatus — which is the surface the report was quoting. Commit 1 puts it there.

4. The missing PID is a co-symptom, and the retry loop is real

This one the brief got right in substance, though not in mechanism. Details below.


Commit 1 — 453acac publish the bounds that can preempt a sweep

Does not add a bound. Publishes the ones that exist, so the misdiagnosis above is not available to the next reader.

File Change
src/types.ts timeoutMs, sweepBudgetMs, missedPasses on FactoryReadinessReconcileStatus; timeoutMs, sweepBudgetMs on FactoryPublicReadinessReconcileHealth
src/orchestrator/factory.ts #readinessReconcileStatus() publishes all three
src/orchestrator/public-health.ts readinessReconcileHealth() + the by-construction serializer pass both through optionalPositive

timeoutMs ends the wait; sweepBudgetMs unwinds the sweep and hands the lease back. The second answers "when does this recover" — the question every reader of this stanza has actually been asking.

Commit 2 — 87dde1e bound the completion release retry

Why the missing PID is not the loop. #releaseAndTerminateAgents (factory.ts:9820) logs no pid available to terminate when #terminationRoots returns { pids: [], status: 'unresolved' }, then falls through. Nothing on that branch reaches failed[] — only a throw from #fleet.release() that is not isAgentAlreadyGoneOnRelease does. The three agents were re-attempted because their release kept failing; the no-PID line was printed on the way past.

Why it never ended. #finishDurableRelease does not throw on a failed release — it returns false and calls #scheduleReleaseRetry, re-arming at DISPATCH_LIFECYCLE_RETRY_MS = 1 000 ms, unbounded. Every re-arm therefore arrives on the resolved path, which is why the .catch() in both schedulers never bounded it, and why a bound written there would have been a fix that never fired. The budget is charged at the scheduling point instead.

What a pass costs. #terminationRoots runs once per agent in the release and once per agent again inside #writeInFlightRegistry (factory.ts:10086) — a process-table scan each — plus a durable read and write. Three agents ≈ ten scans and several state ops per second, indefinitely. #303 already measured this exact shape once, at 1477 state GETs in 111 s (factory.ts:458-467), and bounded the rate of the capacity-wait re-arm in response.

Design choice: (a) bounded retries. Not (b), and not under uncertainty

The brief said to pick (a) if I could not tell a clean exit from a never-recorded PID. I can, and the code already does — so (a) is chosen on evidence, not on caution:

#terminationRoots (factory.ts:9910) returns 'missing' for confirmed-gone (remote placement, or a process scan that came back missing and a resolver that agreed) and 'unresolved' for could-not-determine (no resolver and no recorded pids, an ambiguous scan, a resolver that returned nothing, or one that threw). The error only fires on 'unresolved'.

So (b) is wrong twice over:

  1. 'unresolved' is definitionally "we could not determine". An ambiguous scan is literally more than one candidate matched. Reclassifying that as already-terminated means skipping termination of a process that may be alive, leaving orphans holding worktrees and slots.
  2. It would not have stopped the spin anyway, because the no-PID branch never feeds failed[].

Release is also the opposite shape from #303's capacity wait, which is what makes bounding the count right here and wrong there: it is the last step of a work unit that is already finished — issue closed, writeback acknowledged, batch slot returned — so a release that has failed ten times is not waiting for anything.

Scoped so it cannot abandon work that was never failing

  • Only release re-arms spend the budget. #scheduleDispatchLifecycleRetry takes an explicit releaseAttempt flag, so DispatchLifecycleCapacityError and DispatchLifecycleOwnedElsewhereError still retry forever, exactly as Dispatch is permanently wedged: one agent-less lifecycle holds the only batch slot and cannot be reaped, so every queued row spins at 1 Hz forever #303 intended.
  • Progress refunds the budget, so ten bounds consecutive no-progress passes rather than capping a slow multi-agent release. This terminates: an agent released once is checkpointed and skipped next pass, so the remaining set strictly shrinks and a refund can only be earned finitely often.
  • The durable lifecycle is retained on exhaustion. A takeover or restart re-drives it from the persisted phase. This bounds one process's spin; it does not declare the work unit clean.
  • Keyed by dispatchLifecycleKey — the budget follows the work unit, not an agent, surface, or dispatcher (the AR-448 identity rule).

Observability

Exhaustion logs at error and increments dispatchLifecycleReleaseAbandoned. Every layer of this failure so far has been invisible until someone read stderr by hand; a work unit whose cleanup this process has permanently given up on must not be inferable only from the absence of further log lines.


Red-then-green, observed on the current base

Each fix reverted independently, its test watched go red, restored, watched go green. Verbatim.

Commit 1 — ablated (git stash push src/orchestrator/public-health.ts src/types.ts, tests kept):

 FAIL  src/orchestrator/public-health.test.ts > publicHealthFromHeartbeat (#295) > publishes the deadlines that can preempt a sweep, not just the cadence that cannot
AssertionError: expected undefined to be 5400000 // Object.is equality
 ❯ src/orchestrator/public-health.test.ts:767:50
      Tests  1 failed | 54 passed (55)

Restored: Test Files 1 passed (1) / Tests 55 passed (55)

Commit 2 — ablated (git checkout origin/main -- src/orchestrator/factory.ts, tests kept):

 FAIL  src/orchestrator/factory.test.ts > completion release retry budget (#379) > stops re-arming a completion release that never succeeds, instead of spinning at 1 Hz forever
AssertionError: expected undefined to be 1 // Object.is equality
 ❯ vi.waitFor.timeout src/orchestrator/factory.test.ts:31429:81
      Tests  1 failed | 2 passed | 628 skipped (631)

Restored: Tests 3 passed | 628 skipped (631)

The red is unambiguous at any cadence: dispatchLifecycleReleaseAbandoned does not exist on origin/main. The wait can only end in its own deadline, because the loop re-arms for as long as the process lives — a property of the loop, not of a number chosen in the test.

Both must-not-fire guards passed under the ablation too, which is what makes them guards rather than restatements of the fix.

A flake this PR introduced and then removed

The first version of the commit-2 suite exhausted a ten-attempt budget at the real 1 s floor: +41 s on factory.test.ts. Run beside two other files it pushed an already-300 s combination over an edge and four unrelated tests began failing on timing (reopen-fence, Slack reply routes) — while the same three files passed on origin/main, and factory.test.ts alone passed 631/631 on the branch. This suite already carries #342 and #373; buying a fourth flake to test a fix for a spin is the wrong trade.

dispatchLifecycleRetryMs is now a test-only port override, following babysitterWakeUnreachableRetryMs / startupAgentExitDrainTimeoutMs. Only the delay between attempts moves — the budget under test is the real one. Overhead is now +4 s and the four failures are gone:

src/orchestrator/{public-health,factory,sweep-budget}.test.ts
  branch: Test Files 3 passed (3) / Tests 709 passed (709)   311.64s
  base:   Test Files 3 passed (3) / Tests 704 passed (704)   307.34s

Suite and typecheck

Full suite: Test Files 7 failed | 105 passed | 1 skipped (113), Tests 17 failed | 2280 passed | 1 skipped (2298).

All 7 failing files fail identically on a clean origin/main tree and are sandbox artifacts, not regressions — none is a file this PR touches:

File Cause
src/__tests__/dist-entrypoints.test.ts Cannot find module '/dist/...' — needs a build
.agentworkforce/agents/factory-maintainability/persona.test.ts Cannot find package '@agentworkforce/review-kit'
.agentworkforce/agents/factory-feature-guardian/agent.test.ts bindPreviewTransport is not a function
src/node/factory-persona-card.test.ts deriveAgentCard is not a function
test/e2e/ask-a-teammate.test.ts, src/environments/load-harness.test.ts, src/git/agent-worktree.test.ts same overlay; the last two also flip run-to-run on the clean base

The sandbox has no private-registry access, so npm ci cannot install the tree; tests ran against an overlay of nearest-available local packages (@agentworkforce/runtime, @agentworkforce/persona-kit, @relaycast/a2a), which is why the borrowed packages are missing newer exports.

tsc --noEmit -p tsconfig.json: 225 errors on base, 225 on branch — identical. Zero in src/types.ts, src/orchestrator/factory.ts, src/orchestrator/public-health.ts, or in any line this PR added.

No conflict with #377

Both PRs touch factory.ts and factory.test.ts, so I checked rather than assumed. Closest approach is 12 lines (field declarations, #377 at base 809-813 vs this at 825); the test-file hunks are at 517/20708 for #377 and EOF (31348) here. git merge-tree --write-tree of the two branches exits 0 with no conflict.

What this does not do

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 35f58362-3152-4538-9385-13caf5ac3843


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 87dde1e6f12a5aaf6cd2a8b5ed88262cc43976ee.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/orchestrator/factory.ts Outdated
Comment thread src/orchestrator/factory.ts
Comment thread src/orchestrator/factory.ts Outdated
…e cadence that cannot

THIS COMMIT DOES NOT ADD A BOUND. It publishes the ones that already exist,
because their absence from the health stanza has now been read twice as their
absence from the code — including in the brief that asked for this fix.

WHAT THE STANZA SAID. A wedged 0.1.76 published:

    "readinessReconcile": {
      "state": "healthy", "consecutiveFailures": 0,
      "failureThreshold": 3, "inFlightMs": 268232, "intervalMs": 60000
    }

`intervalMs` is a scheduler tick and cannot preempt anything. Next to an
`inFlightMs` climbing 1:1 with wall clock it is indistinguishable from an
unbounded hang, and there was no field that could tell the two apart. The
reading taken from it — "there is no timeoutMs, so nothing bounds this" — is
false, and it is the reading this stanza invites.

WHAT IS ACTUALLY BOUNDING THAT PASS. Three deadlines, all live on this path in
0.1.76: `relayfileOperationTimeoutMs` per call (#351/#368),
`readinessReconcileTimeoutMs` on the caller's wait (#296), and the aggregate
`sweepBudgetMs` from #374 — `#reconcileReadyIssues` -> `#runOnceWithReadinessDeadline`
-> `runOnce()` -> `#runOnceWithDiscoveryFence` -> `startDiscoverySweepBudget`.
`readinessReconcile` IS the discovery sweep's health stanza; sweep-budget.ts
names it as such. The pass was bounded. It was bounded at 90 minutes, because
`sweepBudgetMs` derives from `reconcileTimeoutMs`, so at 268 s it had 89
minutes left to run and no field said so.

Two numbers now ship: `timeoutMs` (ends the wait) and `sweepBudgetMs` (unwinds
the sweep and hands the lease back). The second is the one that answers "when
does this recover", which is the question every reader of this stanza has
actually been asking.

`missedPasses` also moves onto the heartbeat record. It already existed on the
public projection (#295/#300) and was absent from the heartbeat stanza — which
is the surface an operator opens first, and the one every report so far has
quoted.

NOT A REPORTING BUG, AND DELIBERATELY NOT CHANGED. `state: "healthy"` at
268 s is correct. `derivedReadinessReconcileState` re-derives `stalled` from
`inFlightMs > intervalMs * READINESS_RECONCILE_STALL_INTERVALS`, and that
constant is 10 — so the flip was due at 600 s and the observation window
(14:37Z-14:41Z) closed 5.5 minutes early. Lowering it is the trivially wrong
fix: public-health.ts documents #36's 61-minute post-boot hydration as the
reason a small multiple cries wolf on every cold container.

TESTS, both against the real production numbers:
- must-fire: a heartbeat carrying the bounds publishes both, and reports
  missedPasses 4 for the exact 268232/60000 pass above. Fail-first verified by
  ablation — with only public-health.ts and types.ts reverted it fails
  `expected undefined to be 5400000`.
- must-not-fire: a recorded `0` or negative bound is dropped rather than
  republished as an instant deadline, and an instance predating the fields
  still projects `healthy` with both absent. This one passes before and after
  by construction: it is the guard on the trivially wrong version, not a
  demonstration of the fix.

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

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
…was actually spinning

The `no pid available to terminate ... during completion` lines repeating 15+
times in one evidence payload are a CO-SYMPTOM, not the cause. Fixing the PID
classification would have changed nothing, and this commit explains why before
it changes anything.

WHY THE MISSING PID IS NOT THE LOOP. `#releaseAndTerminateAgents` logs that
line when `#terminationRoots` returns `{ pids: [], status: 'unresolved' }`,
then falls through. Nothing on that branch reaches `failed[]` — only a throw
from `#fleet.release()` that is not `isAgentAlreadyGoneOnRelease` does. So the
three agents were re-attempted because their RELEASE kept failing, and the
no-PID line was printed once per agent per attempt on the way past.

WHY THE LOOP NEVER ENDED. `#finishDurableRelease` does not throw on a failed
release: it returns `false` and calls `#scheduleReleaseRetry`, which re-arms at
`DISPATCH_LIFECYCLE_RETRY_MS` — 1 000 ms, unbounded. Every re-arm therefore
arrives on the RESOLVED path, which is why the `.catch()` in both schedulers
never bounded it and why a bound written there would have been a fix that never
fired. The budget is charged at the scheduling point instead.

WHAT A PASS COSTS, WHICH IS WHY 1 Hz FOREVER IS NOT FREE. Each pass calls
`#terminationRoots` once per agent inside the release AND once per agent again
inside `#writeInFlightRegistry` — a process-table scan each — plus a durable
lifecycle read and write. For the three agents in the report that is order ten
scans and several state operations per second, indefinitely. #303 already
measured this exact shape once, at 1477 state GETs in 111 s, and bounded the
RATE of the capacity-wait re-arm in response. It deliberately left the COUNT
unbounded there, because waiting for capacity is legitimate.

DESIGN CHOICE: (a) BOUNDED RETRIES, NOT (b) RECLASSIFY NO-PID. Not chosen under
uncertainty — the code already tells the two cases apart, and it says (b) is
wrong. `#terminationRoots` returns `'missing'` for confirmed-gone (a remote
placement, or a process scan that came back missing AND a resolver that agreed)
and `'unresolved'` for could-not-determine (no resolver and no recorded pids,
an AMBIGUOUS scan, a resolver that returned nothing, or one that threw). The
error only fires on `'unresolved'`. Treating that as already-terminated would
mean skipping termination of a process that may well be alive — an ambiguous
scan is literally "more than one candidate matched" — leaving orphans holding
worktrees and slots. And it would not have stopped the spin regardless, per the
first section.

Release is also the opposite shape from #303's capacity wait, which is what
makes bounding the count right here and wrong there: it is the last step of a
work unit that is already finished — issue closed, writeback acknowledged,
batch slot returned — so a release that has failed ten times is not waiting for
anything. Ten attempts at the 1 s floor is ~10 s of genuine retry, which covers
a control-plane blip or a lease handover and does not cover a permanent
failure.

SCOPED SO IT CANNOT ABANDON WORK THAT WAS NEVER FAILING:
- Only release re-arms spend the budget. `#scheduleDispatchLifecycleRetry`
  takes an explicit `releaseAttempt` flag, so a `DispatchLifecycleCapacityError`
  or `DispatchLifecycleOwnedElsewhereError` — both legitimate waits on someone
  else — still retries forever, exactly as #303 intended.
- Progress refunds the budget, so ten bounds CONSECUTIVE no-progress passes
  rather than capping a slow multi-agent release. This terminates: an agent
  released once is checkpointed and skipped next pass, so the remaining set
  strictly shrinks and a refund can only be earned finitely often.
- The durable lifecycle is RETAINED on exhaustion. A takeover or a restart
  re-drives it from the persisted phase. This bounds one process's spin; it
  does not declare the work unit clean.
- Keyed by `dispatchLifecycleKey`, so the budget follows the work unit rather
  than an agent, a surface or a dispatcher — the AR-448 identity rule.

Exhaustion is logged at `error`, not `warn`, and increments
`dispatchLifecycleReleaseAbandoned`. Every layer of this failure so far has
been invisible until somebody read stderr by hand, and a work unit whose
cleanup this process has permanently given up on must not be inferable only
from the absence of further log lines.

TESTS (3), against a fleet that reproduces the production shape exactly —
`release()` throws for `issue-done` and `resolveAgentPid` returns
`'unresolved'`, so the same no-PID line is emitted on every pass:
- must-fire: the dead-letter counter reaches 1, the exhaustion error is logged,
  and three further seconds of wall clock buy no additional release attempts.
  Fail-first verified by ablation: with factory.ts reverted it fails after
  40 543 ms with `expected undefined to be 1` — the wait can only end in its
  own deadline, because the loop re-arms for as long as the process lives.
  That is a property of the loop, not of any number chosen in the test.
- must-not-fire: a release that succeeds still completes the work unit and
  releases each agent exactly once, with the counter unset. The trivially wrong
  way to stop a retry loop is to stop retrying.
- must-not-fire: a release that fails several times and then succeeds still
  completes, with the counter unset — the transient case the retry exists for.
  Both must-not-fires passed under the ablation too, which is what makes them
  guards rather than restatements of the fix.

RETRY CADENCE IS NOW AN INJECTABLE PORT, and that is a test-stability fix in
its own right rather than a convenience. Exhausting a ten-attempt budget at the
real 1 s floor costs ten real seconds per case; the first version of this suite
did exactly that and added 41 s to `factory.test.ts`. Run beside two other
files it pushed an already-300 s combination over an edge and four UNRELATED
tests began failing on timing — the reopen-fence and Slack-reply-route cases —
while the same three files passed on `origin/main` and `factory.test.ts` alone
passed 631/631 on the branch. Buying a fourth flake in this suite (it already
carries #342 and #373) to test a fix for a spin is the wrong trade.

`dispatchLifecycleRetryMs` follows the existing convention for exactly this —
`babysitterWakeUnreachableRetryMs`, `babysitterWakeUnreachableEscalateMs`,
`startupAgentExitDrainTimeoutMs` are all test-only port overrides of a built-in
timing. Only the delay between attempts moves; the BUDGET under test is the
real one. Overhead is now +4 s, the four unrelated failures are gone (709/709
on the same three files), and the ablation still fails with `expected undefined
to be 1` — unambiguously, because `dispatchLifecycleReleaseAbandoned` does not
exist on `origin/main` at any cadence.

The transient case sets a failure count on the fake rather than flipping a flag
from the test body, so it cannot race the cadence it runs under.

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

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
…e path

Answers three P1 findings on #379. The first is the important one: the bound
as first written DID NOT FIRE in production, and the review caught it.

1. THE BUDGET RESET ON THE PATH THAT MATTERS, SO THE BOUND NEVER FIRED.

   `#driveDispatchLifecycle` discards `#finishDurableRelease`'s boolean in its
   `phase === 'releasing'` branch, and that method returns `false` rather than
   throwing on a failed release. So a FAILED release makes the drive RESOLVE,
   and the scheduler's success handler ran on every re-arm — where it called
   `#clearReleaseAttempts`. The counter was zeroed once per pass and could
   never reach the cap.

   This is the same never-fires shape the first version of this commit
   correctly rejected in the `.catch()`, moved one layer over into the
   `.then()`. Diagnosing the resolved path as the live one and then putting the
   refund on it was the error.

   The refund is removed from the scheduler entirely. It now happens only where
   success is actually known: `#finishDurableRelease` clears the budget on real
   per-agent progress and again when the work unit completes.

   WHY THE ORIGINAL TESTS MISSED IT. `#usesDurableDispatchLifecycle()` is
   `durableOwnership ?? placementLocality === 'remote'`, and `FakeFleetClient`
   places locally, so all three original cases exercised `#scheduleReleaseRetry`'s
   own timer — which has no success handler and therefore no reset. The
   deployed Factory places remotely. The suite proved a property of the path
   production does not take.

   New must-fire on the DURABLE path (`RemoteLifecycleFleetClient` +
   `InMemoryStateStore`), asserting the counter SURVIVES ACROSS RE-ARMS rather
   than that a dead-letter is reachable by some path. Fail-first verified by
   ablation: restore the `#clearReleaseAttempts(key)` line and only that case
   fails, `expected undefined to be 1` after 10 125 ms, while the three local
   cases still pass — which is what pins the discrimination to the durable path.

2. THE WRONG BUDGET WAS CHARGED.

   The generic arm of the drive's `.catch()` re-arms for dispatch, publishing
   and recovery failures as well as releases, and it charged all of them. That
   would dead-letter a work unit that was never stuck in a release loop.

   Charging is now confined to `#scheduleReleaseRetry`, whose every caller is a
   release failure: the three inside `#finishDurableRelease`, and
   `#completeIssue`'s catch once `releaseReasonForRetry` is set. The generic
   re-arm passes no charge at all.

   Pinned by a call-site audit rather than by a behavioural test, and that is
   deliberate. I could not reach that arm from a realistic fixture — forcing
   durable lifecycle reads to throw makes the agent-exit handler fail before any
   lifecycle retry is scheduled, so a test built that way passes whether or not
   the narrowing is present. Confirmed by ablation: with `releaseAttempt = true`
   restored, the fixture-based version still passed, and instrumenting it showed
   zero `durable dispatch lifecycle retry failed` warnings — the branch was
   never entered. Shipping that would have been a test that proves nothing, so
   the audit states the structure instead.

   Known gap, stated plainly: a release failure that THREW out of
   `#finishDurableRelease` would reach the generic arm and re-arm unbounded.
   Every failure path in that method returns `false` and schedules its own
   retry, so this is not a reachable shape today, and if one appears it degrades
   to the pre-existing unbounded behaviour rather than to a wrong dead-letter.

3. THE DEAD-LETTER LEAKED THE SLOT.

   Trading an unbounded 1 Hz spin for a permanently leaked in-flight record is
   not obviously the better failure: a spin is loud and self-describing, while a
   leaked slot silently reduces dispatch capacity until the process is
   restarted. Local completion never calls `batch.complete`, so exhaustion left
   the work unit in flight forever.

   `#releaseDeadLetteredSlot` now hands the batch slot back, drops any
   uncompensated claim, rewrites the in-flight registry, and admits whatever was
   queued behind it — a freed slot nothing is admitted into is only half the
   repair. The durable lifecycle is still deliberately RETAINED in `releasing`,
   so a successor or restart re-drives the same cleanup with a fresh budget;
   freeing a process-local slot is not a terminal phase and does not declare the
   work clean. The work unit therefore ends up recoverable, never merely
   abandoned.

   Must-fire asserts the slot is released after exhaustion. Fail-first by
   ablation: stub the call out and it fails with the work unit still in flight.

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

Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
@miyaontherelay
miyaontherelay force-pushed the fix/reconcile-wedge-0825 branch from 87dde1e to 514cca1 Compare August 25, 2026 21:09
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 514cca143319e00f3f84afc0807bb18fc80c2965.

@miyaontherelay

Copy link
Copy Markdown
Contributor Author

Round 2: rebased onto effd4c75, three P1s fixed, package failure diagnosed

Rebased onto the post-#377 main (effd4c75) — clean, no conflicts — and all ablations below were re-run on that base, not the old one.

Fixes in 514cca1; replies on each thread. Summary:

Finding Status
P1 budget reset → bound never fired Confirmed by reading and by test. Refund removed from the scheduler; new durable-path must-fire asserts the counter survives across re-arms
P1 wrong budget charged Charging confined to #scheduleReleaseRetry; pinned by call-site audit, with an honest note on why a behavioural test was not possible
P1 dead-letter leaks the slot #releaseDeadLetteredSlot frees the slot and retains the durable row — recoverable, not abandoned

The first one was a real defect and the review was right: my own tests could not have caught it, because FakeFleetClient places locally and production places remotely, so every original case exercised a path with no success handler and therefore no reset.


The package check: inherited, and not the @agent-relay/sdk failure

Checked rather than assumed, and it is a different cause from the one #377 hit — that was No matching version found for @agent-relay/sdk@11.8.4; this is three failing tests. main itself is currently red on CI, and every test that failed on this PR is documented failing on main with no changes of mine:

Failing test on #379 (run 32894127383, sha 87dde1e) Also fails on main
factory.test.ts > preserves the GitHub reporter on a Linear mirror and authorizes their escalation reply ✅ run 32897421780, sha effd4c75 — same expected undefined to be 1
factory.test.ts > fences a Slack reply route that registers while the terminal drain is running ✅ run 32897421780, sha effd4c75
cli/teammate-mcp.test.ts > exposes discover and bounded ask ... ✅ run 32858119443, sha 46919f2a

main's run also failed two more (tailscale-preview timing, atomic-json-file concurrency) that this PR did not — the set varies per run, which is the signature of load-sensitive timing tests on a shared runner rather than of a code defect.

My six budget tests all passed in that CI run, including on the CI runner's timing:

✓ stops re-arming a completion release that never succeeds, instead of spinning at 1 Hz forever  1027ms
✓ does not bound a completion release that succeeds  74ms
✓ still completes a release that fails a few times and then succeeds  287ms

Conclusion: inherited flakiness on a currently-red main, not caused by this change. I have not tried to fix it here.

Verification on the new base

  • factory.test.ts + public-health.test.ts: 693 passed, exit 0
  • tsc --noEmit: 225 errors on base, 225 on branch — identical, zero in any file or line this PR touches
  • Each of the three fixes ablated independently and watched go red; verbatim output on the threads

On splitting

You offered the split. My recommendation is keep it as one PR: commit 1 (29aa19d, observability) was untouched by all three findings and is still clean, but commit 2 is now green with a discriminating test per finding, so splitting buys a rebase and a second review cycle for no remaining benefit. If you would rather land the observability half now regardless, say so and I will split it out in a few minutes — commit 1 is independent and cherry-picks cleanly.

@miyaontherelay

Copy link
Copy Markdown
Contributor Author

package re-run on 514cca1 — 3 failures → 1, and the remaining one is on main

Run 32899489523: Test Files 1 failed | 111 passed | 1 skipped (113).

The single remaining failure is:

FAIL src/orchestrator/factory.test.ts > FactoryLoop > fences a Slack reply route that registers while the terminal drain is running
AssertionError: expected undefined to be 1 // Object.is equality

Same test, same assertion, fails on main with none of my changes — run 32897421780 at effd4c75. main is red on CI right now; this PR is a strict subset of its failures.

All six budget tests pass on the CI runner:

✓ stops re-arming a completion release that never succeeds, instead of spinning at 1 Hz forever  1120ms
✓ does not bound a completion release that succeeds                                                95ms
✓ still completes a release that fails a few times and then succeeds                              179ms
✓ exhausts the budget on the durable lifecycle, where the failed release resolves instead of throwing  578ms
✓ releases the slot when the budget is exhausted rather than leaking the work unit                 607ms
✓ charges the release budget from the release scheduler only                                         4ms

Still DO NOT MERGE — the gate is the principal's.

@miyaontherelay
miyaontherelay merged commit b5f2a6c into main Aug 25, 2026
7 of 8 checks passed
@miyaontherelay
miyaontherelay deleted the fix/reconcile-wedge-0825 branch August 25, 2026 21:58
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