Skip to content

fix(factory): skip the orphan-recovery context on a dry-run sweep (#348) - #349

Merged
khaliqgant merged 2 commits into
mainfrom
lane/348-dryrun-orphan-context
Aug 23, 2026
Merged

fix(factory): skip the orphan-recovery context on a dry-run sweep (#348)#349
khaliqgant merged 2 commits into
mainfrom
lane/348-dryrun-orphan-context

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 23, 2026

Copy link
Copy Markdown
Member

Fixes the deliverable in #348: a dry run must not need a workspace identity to decide what it would do.

What changed

#githubOrphanRecoveryContext() read fleet.roster() whenever the issue source is GitHub, regardless of dryRun. Since #343 a read-only CLI client refuses to mint a workspace identity, and roster() mints on demand — so the container's start-disabled run-once --dry-run gate logged could not establish orphan-recovery safety context; preserving in-progress issues on every sweep.

The context was already provably unused on that path: mayRecoverGithubOrphan in #performRunOnce is !dryRun, and #reconcileOrphanedGithubInProgress returns 'dry run does not release an in-progress claim' before it so much as looks at the context. Skipping it is therefore behaviour-preserving for what the sweep records — it only stops the sweep needing an identity it never uses.

IterationReport.orphanRecoveryDegraded now reports the state, distinguishing the two causes that are not the same event:

  • 'dry-run' — expected and benign, the sweep never intended to release a claim
  • 'context-unavailable' — a live sweep tried to build the context and could not

The githubOrphanRecoveryContextFailures counter and its warn stay on the live path, where an absent context genuinely is a degradation.

Tests

A must-fire / must-not-fire pair plus a control, all on one orphan-shaped fixture:

arm asserts
must-not-fire a dry-run sweep makes zero roster reads — measured with a fleet that answers roster() normally, so the zero is the guard and not a refusal
control / must-fire the same fixture, same fleet class, only dryRun differs: roster is read, the orphan is recovered, orphanRecoveryDegraded is absent
regression a dry run against a client shaped like #343's refusal completes, rather than logging a safety-context failure

Ablated (guard removed): both must-not-fire arms fail on expected 1 to be +0, the control still passes. Inverted (!dryRun): all three fail. Verified locally, not assumed.


⚠️ This does NOT fix the 10-minute timeout, and #348's diagnosis of it is wrong

I was asked to measure before assuming. I did, and the measurement does not support the issue's mechanism. Three findings, all from the deploy logs themselves.

1. The A/B is confounded — the 0.1.69 "success" never ran the sweep

#348 states that phase: ready on run 32661804498 is "positive proof the sweep ran and finished". That run's own probe record says otherwise:

"runOnceDryRun": {
  "exitCode": 3, "durationMs": 17125, "stdoutBytes": 133,
  "stderrText": "[factory] skipped discovery because another process owns the sweep lease {\"owner\":\"37:72bc3446-…\",\"epoch\":117,…}",
  "tolerated": {
    "exitCode": 3,
    "reason": "another process owns the discovery sweep lease; run-once deferred discovery (discoveryDeferred=sweep-in-flight) and performed no work"
  }
}

performed no work. The probe exited in 17s because it lost a lease race, and the gate tolerated that as a pass. The 0.1.70 run (32660636060) claimed the lease (epoch 116) and ran the full sweep. The two runs differ in lease ownership, not just in version. phase: ready discriminates "the probe returned tolerably", not "the sweep completed".

2. The same 600s SIGTERM happens on 0.1.63, seven releases before #343

Scanning the last 40 Deploy Factory runs, exactly four executed a runOnceDryRun probe:

run factory exit durationMs outcome
32262365552 0.1.63 3 62411 deferred
32282623135 0.1.63 null (SIGTERM) 600017 timed out
32661804498 0.1.69 3 17125 deferred, no work
32660636060 0.1.70 null (SIGTERM) 600034 timed out

32282623135 (2026-08-19, 0.1.63) predates #343 by four days and seven releases. It has no read-only client and no orphan-recovery warn — orphan recovery succeeded there — and it still hit the identical 600s SIGTERM with the identical trajectory. Every start-disabled deploy that actually ran the sweep has timed out, before and after #343. #343 did not regress this; it made an unrelated pre-existing failure the only remaining outcome, because the 0.1.69 comparison happened to skip the work.

3. Where the 10 minutes actually goes: an uncapped sequential PR-candidate read

Both timed-out runs end the same way — and stdoutBytes: 0, stderrBytes: 4544. The process emitted ~4.5KB and then went completely silent for the rest of the ten minutes. It hung mid-sweep; it did not finish and fail to exit.

The last thing either run logged before going silent:

[factory] relayfile listTree completed {"phase":"dependency PR probe resolution",
  "prefix":"/github/repos/AgentWorkforce/cloud/pulls/","elapsedMs":3932,"count":2831}
[factory] relayfile listTree completed {"phase":"dependency PR probe resolution",
  "prefix":"/github/repos/AgentWorkforce__cloud/pulls/by-id/","elapsedMs":903,"count":1134}

resolveIssuePrFromMount (src/orchestrator/factory.ts:18979) then does this, per dependency identity:

for (const path of paths) {                              // ~3,965 candidates
  if (!path.endsWith('.json')) continue
  const pr = await readProbePrCandidate(mount, path)     // sequential remote read, UNLOGGED

Every PR in AgentWorkforce/cloud, read one at a time, with no candidate cap and no progress logging. At ~150ms per remote read that is ~10 minutes of silence — which is exactly the observed signature. The sibling gh resolver caps at PROBE_PR_GH_CANDIDATE_LIMIT = 200 and warns when it truncates; the mount resolver has no equivalent.

I have not fixed this here. The two obvious remedies trade off against each other and the choice is yours, not mine to make unilaterally in a #348 lane:

  • Bounded concurrency (e.g. 16-way) — preserves resolution semantics exactly, ~16× wall-clock cut, but fans out against a backend the codebase already treats as overload-sensitive ([factory] Discovery overload backoff ignores the advertised Retry-After and discards a whole sweep on one 429 #297's 429 ratchet, DEFAULT_LOCAL_MOUNT_MAX_CONCURRENCY = 4).
  • Newest-first ordering + a candidate cap + a truncation warn, mirroring the gh resolver — cheap and bounded, but on a 2,831-PR repo a cap small enough to help could stop resolving an older PR, silently weakening the dependency-merged check.

Either way it wants progress logging: a stage that can burn ten minutes should not be invisible.

I did not touch the 10-minute budget. Nothing in the measurement suggests it is wrong; the stall it caught is real.

The two side items

  • .integrations/discoverymissing required scope: fs:read — present in the 0.1.63 run too, so also not from fix(cli): stop read-only commands registering a relay agent (factory-cloud#55) #343. deriveDescriptorsFromMount emits paths relative to the local .integrations mirror, and #doResolveIntegrationInstructions (factory.ts:1085) passes .integrations/discovery straight to the cloud listTree, where no such remote path exists and FACTORY_RELAYFILE_SCOPES grants nothing for it. It is a local-mirror path used as a remote one. Non-fatal (caught, logged) — left alone rather than guessed at, since the correct remote prefix depends on the descriptor package's contract.
  • The repeating stale-mount refresh loop — also present on 0.1.63. Each cycle is a few seconds on an unref'd timer, so it is not the ten minutes; it is noise plus a real relayfile-side symptom (the mount stops reconciling ~90s after each successful relaunch, so #superviseLocalMount re-heals it forever with no backoff and no cap). Worth its own issue. Note LocalMountOptions.refreshStaleMount exists and no production caller ever sets it — a read-only command has no writeback to protect and arguably should.

Suggested next steps

  1. Merge this — it is correct on its own terms and removes a spurious failure signal from every dry run.
  2. Reopen the timeout as its own issue against resolveIssuePrFromMount, not against fix(cli): stop read-only commands registering a relay agent (factory-cloud#55) #343, with finding 3 as the starting point.
  3. Correct #343 regressed start-disabled deploys: run-once --dry-run exceeds its 10-minute budget #348's provenance: fix(cli): stop read-only commands registering a relay agent (factory-cloud#55) #343 is not the cause. The 0.1.69 control never exercised the code under test.

🤖 Generated with Claude Code


Summary by cubic

Skips building the GitHub orphan‑recovery safety context during dry‑run sweeps so run-once --dry-run no longer attempts to mint a workspace identity. Previously dry runs read the fleet roster and failed under the read‑only client from #343; now dry runs preserve in‑progress issues and report this explicitly.

  • Satisfies #343 regressed start-disabled deploys: run-once --dry-run exceeds its 10-minute budget #348: a dry run does not require a workspace identity.
  • Gates #githubOrphanRecoveryContext on dryRun; adds an info log and githubOrphanRecoveryContextSkippedDryRun counter; includes orphanRecoveryDegraded in the run summary.
  • Behavior change is limited to dry runs: report sets orphanRecoveryDegraded: 'dry-run'. Live sweeps are unchanged; if the context cannot be built, report sets 'context-unavailable' and the existing warn/counter still signal a real degradation.
  • Tests cover: (1) dry runs make zero roster reads, (2) live sweeps still read the roster and recover orphans, (3) dry runs complete against a client that refuses identity minting.
  • No migration required; IterationReport.orphanRecoveryDegraded is additive.

Written for commit aadd633. Summary will update on new commits.

Review in cubic

`#githubOrphanRecoveryContext()` read `fleet.roster()` whenever the issue
source is GitHub, regardless of `dryRun`. Since #343 a read-only CLI client
refuses to mint a workspace identity, and `roster()` mints on demand — so the
container's start-disabled `run-once --dry-run` gate logged
`could not establish orphan-recovery safety context` on every sweep.

The context was already provably unused on that path: `mayRecoverGithubOrphan`
in `#performRunOnce` is `!dryRun`, and `#reconcileOrphanedGithubInProgress`
refuses under `dryRun` before it so much as looks at the context. Gathering it
was not free, though — it required an identity to decide what the sweep WOULD
do. Now a dry run skips it and the report says so, via a new
`IterationReport.orphanRecoveryDegraded` that distinguishes the benign
`dry-run` skip from a live sweep's real `context-unavailable` degradation. The
failure counter and warn stay for the live path, where an absent context IS a
degradation.

Tests are a must-fire / must-not-fire pair plus a control, all on one fixture:
a dry-run sweep does not read the roster (measured with a fleet that answers
normally, so the zero is the guard and not a refusal); a live sweep still reads
it and still recovers the orphan; and a dry run against a client shaped like
#343's refusal completes rather than logging a failure. Ablating the guard
fails both must-not-fire arms; inverting it fails all three.

NOT fixed here, and #348's diagnosis is wrong about it: this does not bring
`run-once --dry-run` under its 10-minute budget, because #343 never took it
over. See the PR comment for the measurement.

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

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 18 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eb064be0-b8b7-493c-a3b1-d48a0c1d532f

📥 Commits

Reviewing files that changed from the base of the PR and between f06267c and aadd633.

📒 Files selected for processing (3)
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
  • src/types.ts

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 a5e7be339dd31f6401c187b04a5b7dc3bbcc1321.

@khaliqgant

Copy link
Copy Markdown
Member Author

Report — factory-348-dryrun-0823

What landed

The deliverable, and only the deliverable: #githubOrphanRecoveryContext(dryRun) returns early on a dry run, so a run-once --dry-run sweep never calls fleet.roster() and never needs a workspace identity to decide what it would do. The context was already provably unused on that path, so nothing the sweep records changes — only the identity requirement goes away. IterationReport.orphanRecoveryDegraded reports 'dry-run' for the benign skip and 'context-unavailable' for a live sweep that genuinely could not build one.

Test arms and their mutation results are in the PR body. Both mutations were actually run, not reasoned about: ablating the guard fails the two must-not-fire arms on expected 1 to be +0 while the control still passes; inverting it fails all three.

What I could not honestly deliver

This does not bring the start-disabled deploy under its 10-minute budget, and #343 never took it over. The full measurement is in the PR body; the three load-bearing facts:

  1. The 0.1.69 arm of the A/B never ran the sweep — its own probe record reads "reason": "another process owns the discovery sweep lease; run-once deferred discovery … and performed no work", exit 3 in 17s. It lost a lease race. phase: ready discriminates "the probe returned tolerably", not "the sweep completed", so it cannot be the known-present needle.
  2. Run 32282623135 on 0.1.63 — 2026-08-19, seven releases before fix(cli): stop read-only commands registering a relay agent (factory-cloud#55) #343, orphan recovery working, no read-only client — hit the identical 600s SIGTERM with the identical trajectory. Of the four runOnceDryRun probes in the last 40 deploys, two deferred on the lease and two actually ran the sweep; both that ran timed out.
  3. The ten minutes is resolveIssuePrFromMount (src/orchestrator/factory.ts:18979) reading ~3,965 PR candidates sequentially through unlogged mount.readFile, with no candidate cap — the sibling gh resolver caps at PROBE_PR_GH_CANDIDATE_LIMIT = 200 and warns on truncation. The signature matches exactly: stdoutBytes: 0, stderrBytes: 4544, and total silence from the last listTree until SIGTERM. It hung mid-sweep; it did not finish and fail to exit.

I did not raise the budget, and nothing in the measurement suggests it is wrong. I also did not fix the fan-out: the two remedies (bounded concurrency vs. newest-first ordering plus a cap) trade off against #297's overload-sensitivity and against dependency-resolution correctness respectively, and that is a call for whoever owns the merge, not for a #348 lane to make unilaterally.

Side items — reported, not changed

  • .integrations/discoverymissing required scope: fs:read. Also present on 0.1.63, so also not from fix(cli): stop read-only commands registering a relay agent (factory-cloud#55) #343. @agent-relay/integration-prompts defaults DEFAULT_DISCOVERY_ROOT = '.integrations/discovery' — a local mirror path — and #doResolveIntegrationInstructions (factory.ts:1085) hands it straight to the cloud listTree. The remote equivalent would be /discovery, but FACTORY_RELAYFILE_SCOPES grants nothing for it, so a correct fix needs both the path and a new scope — and that scope list carries an explicit warning that one invalid path fails the entire delegated-token mint, leaving every read 403ing. deriveDescriptorsFromMount accepts a discoveryRoot option, so the path half is easy; the scope half is not something to guess at. Non-fatal today (caught and logged).
  • The repeating stale-mount refresh loop. Also present on 0.1.63. Each cycle is seconds on an unref'd timer, so it is not the ten minutes — it is log noise over a real relayfile-side symptom: the mount stops reconciling ~90s after each successful relaunch, and #superviseLocalMount re-heals it forever with no backoff and no cap. Worth its own issue. Note LocalMountOptions.refreshStaleMount exists and no production caller ever sets it — a read-only command has no writeback to protect and arguably should.

CI

Per-job, run 32664746250:

job result
package fail — 5 failed / 2068 passed
kubernetes-provider-e2e pass
load-e2e pass
verification-gate-e2e pass
verification-stack-e2e pass
request pass

The 5 failures are a red base, not this PR, and not #342. Main's own CI is already failing at my base commit ddf6486 (run 32661015038) with the identical set — same five test names, same count:

src/cli/fleet.test.ts       > waits for its in-flight park confirmation ('acked')
src/cli/fleet.test.ts       > does not attribute a third-party GitHub close from an in-flight 'explicit acknowledged' receipt
src/cli/fleet.test.ts       > does not attribute a third-party GitHub close from an in-flight 'legacy void' receipt
src/cli/fleet.test.ts       > serializes terminal completion that starts after the ready read against the dispatch claim
src/orchestrator/factory.test.ts > settles post-spawn completion waits when a later spawn reaches the held-agent deadline

Four of the five are in src/cli/fleet.test.ts, which this PR does not touch. The fifth was added by ddf6486 itself (#322, "settle post-spawn waits"). I checked #342 first as instructed — it is not that flake: #342 names "releases a late placement when another owner reclaimed the lifecycle" and describes nondeterminism, whereas these fail as the same set every run. I reproduced two of them locally against the unmodified base (files reverted, git status clean) before writing a line of the fix, and again with the fix applied — identical either way.

My three new tests ran in this CI job and are in the 2068 passed. Diff is confined to factory.ts, factory.test.ts, types.ts; grep confirms orphanRecoveryDegraded / githubOrphanRecoveryContext have no consumers outside those three files.

⚠️ No bot actually reviewed this PR, despite three green checks: CodeRabbit is Review rate limited, Devin is trial expired and no credits remaining, cubic is skipping. Read the detail column, not the colour.

Recommendation

Merge this on its own terms — it is correct, tested, and removes a spurious failure signal from every dry run. Then, separately: reopen the 10-minute timeout against resolveIssuePrFromMount rather than #343, and correct #348's provenance. #343 did not cause it; it only removed the coincidence that was hiding it, because the 0.1.69 control never exercised the code under test.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head aadd633ebfb0b91edeafd65f3f2fae17892f33b2.

@khaliqgant

Copy link
Copy Markdown
Member Author

Merging. This fixes the regression I introduced by merging #343, and it clears the bar cleanly.

All 5 jobs green at aadd633e, mergeable: clean, zero live review threads.

The tests are what I asked for and the control is the part that matters:

it('does not read the roster on a dry-run sweep')
  expect(fleet.rosterCalls).toBe(0)
  expect(report.orphanRecoveryDegraded).toBe('dry-run')
  expect(counters.githubOrphanRecoveryContextSkippedDryRun).toBe(1)

it('CONTROL: still reads the roster and recovers the orphan on a live sweep')
  expect(fleet.rosterCalls).toBeGreaterThan(0)
  expect(report.dispatched.map(r => r.issue.key)).toEqual(['348'])
  expect(counters.githubOrphanedInProgressRecovered).toBe(1)

Without that second test the first one passes if orphan recovery is broken everywhere, which is a strictly worse bug than the one being fixed. Asserting both directions is what makes the skip provably scoped to dry runs.

And you honoured the hard constraint: the 10-minute budget is untouched. I checked the whole diff for it. Raising the timeout would have hidden the regression rather than fixing it, and entrypoint.mjs documents why that budget went 30 → 10 minutes — a stalled preflight is the outage it exists to prevent.

Note your CI was red earlier through no fault of yours: main was broken by my #322 merge (four failures in src/cli/fleet.test.ts). I reverted it as f06267c0 and updated your branch; package went straight from failure to success with no change from you. That also confirmed the revert diagnosis, so your PR did double duty.

This restores the start-disabled rehearsal deploy — the gate that proves a full cycle runs against the real host without side effects, which we lost this evening and which I want back before the next release.

@khaliqgant
khaliqgant merged commit 8d6b89a into main Aug 23, 2026
9 checks passed
@khaliqgant
khaliqgant deleted the lane/348-dryrun-orphan-context branch August 23, 2026 21:42
khaliqgant added a commit that referenced this pull request Aug 24, 2026
#359)

* fix(health): a deferred sweep must not erase the last real measurement

CodeRabbit, Major on #358, and correct. `#recordReadinessSweepOutcome` folded a
deferred report into the counts snapshot like any other. A deferred pass settles
successfully in milliseconds having enumerated nothing, so its zeroes replaced
the last enumerating sweep's numbers — and where another process holds the
discovery lease for any length of time (the #347/#349 condition), EVERY pass
would publish `candidates: 0` and the measurement this whole change exists to
provide would be unrecoverable.

The marker now lives apart from the counts. `candidates`/`dispatched`/`skipped`
describe the last sweep that ENUMERATED; `discoveryDeferred` describes the most
recent pass. Present together they say "these numbers are from an earlier pass";
present alone it says "nothing has enumerated yet, and a held lease is why" —
which the previous shape could not express at all, because the projection
dropped the marker whenever the trio was absent.

Not taken from the review: freezing `lastCompletedAtMs` on deferral. The
#295/#296 stall derivation reads that timestamp against `lastStartedAtMs`, so
freezing it would leave `lastStarted > lastCompleted` on every deferred pass and
report a daemon that is correctly deferring to another owner as hung after ten
intervals — a false alarm on the exact surface this work exists to make
trustworthy. It still moves; the test asserts that it does, and says why.

Also CodeRabbit, trivial: `docs/deployed-diagnostics.md` listed four of the
fourteen skip codes. All fourteen are now documented, grouped by what an
operator should do about each, with the `other` fold-in stated.

Tests: a sweep that enumerates, then deferred passes, asserting the counts
survive, the marker appears, and `lastCompletedAtMs` still advances; the
first-pass-defers case asserting the marker publishes with no counts at all; and
a projection unit test for the same independence. Verified by ablation —
restoring the old single-record write fails both deferral tests.

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

* fix(health): date the retained counts with their own measurement timestamp

Review hold on #359, and correct. Separating the deferral marker from the
arithmetic stopped a deferred pass zeroing the last enumeration, but left the
retained counts with no time coordinate: `#reconcileReadyIssues` advances
`lastCompletedAtMs` on every settled pass including a deferred one, while the
counts now stay put. So the payload paired arbitrarily old
`candidates`/`dispatched`/`skipped` with an ever-fresh completion stamp, and a
reader could see that the newest pass deferred but not whether the measurement
was one interval or four days old — the freshness this surface exists to expose.

It also made the contract contradict itself: `types.ts` said `lastCompletedAtMs`
identified the pass the counts describe, and the docs said both that and the
opposite two paragraphs later.

`lastEnumeratedAtMs` is now part of the same atomic snapshot as the counts,
written only when a pass actually enumerates, and projected through the
authenticated status, the public health record and `factory diagnose`. Equal to
`lastCompletedAtMs` on a daemon sweeping normally; where they differ, the gap is
exactly how stale the counts are. The recorder takes the caller's completion
stamp rather than reading the clock again, so on an enumerating pass the two
cannot drift apart by a tick — which is what makes the equality assertable.

`lastCompletedAtMs` still moves on deferral, unchanged: the #295/#296 stall
derivation reads it against `lastStartedAtMs`, and freezing it would report a
daemon correctly deferring to another owner as hung after ten intervals.

Two codex P2s on the same head, both real:

- The completion log drew `skipReasons` from the retained snapshot while drawing
  the counts from the current report, so a deferred pass printed `skipped: 0`
  beside a non-empty breakdown — a line contradicting its own arithmetic, on the
  surface a local operator reads. It now derives the breakdown from the report
  it is describing.
- The docs sent `read-failed` and `dispatch-failed` to `lastErrorClass`. Those
  codes count per-item failures an otherwise-successful pass absorbed and
  continued past (#292/#297), and the success path clears `lastErrorClass` — so
  the guidance pointed at a field guaranteed absent in exactly that scenario.
  Reworded to name the container-log lines that do carry the detail, and to note
  that a rising `read-failed` beside `state: healthy` is the #297 signature.

Tests: the enumeration stamp equals the completion stamp on a pass that
enumerated, stays pinned across repeated deferrals while `lastCompletedAtMs`
advances past it, and is absent until a sweep enumerates; plus a recording-logger
test asserting every deferred completion line is internally consistent while the
published surface still retains the real measurement. Verified by ablation —
sourcing the stamp from `lastCompletedAtMs` fails the deferral test on the
equality, and restoring the retained-breakdown log fails the new log test.

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

* fix(cli): scope deferral wording to latest pass

* fix(health): keep sweep outcome attribution current

* docs(factory): align sweep count terminology

* docs(health): name enumerating sweep consistently

* test(factory): wait for actual late placement race

* fix(diagnose): classify legacy deferred counters

* fix(diagnose): preserve rejected sweep evidence

---------

Co-authored-by: Claude Opus 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.

1 participant