Skip to content

feat(orchestrator): distinguish a mount serving empty trees from one timing out (#351) - #363

Open
khaliqgant wants to merge 1 commit into
mainfrom
lane/351-empty-vs-timeout
Open

feat(orchestrator): distinguish a mount serving empty trees from one timing out (#351)#363
khaliqgant wants to merge 1 commit into
mainfrom
lane/351-empty-vs-timeout

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 24, 2026

Copy link
Copy Markdown
Member

Follow-up to #354. Closes a requirement asked for twice on #351 that #354 shipped without, plus two review points it answered by inference rather than assertion.

The gap

#354 made a hung relayfile read loud — per-call deadline, named RelayfileOperationTimeoutError, rising consecutiveFailures. It left the other half open, and you named it explicitly:

distinguish a bounded call that returned empty from one that timed out. If the mount starts serving empty trees instead of hanging, a timeout-only signal will go quiet and look healthy — and we will be back here reading a green surface with no dispatch.

A mount answering reads with nothing raises no timeout, no failure and no lastError. The sweep completes healthy and dispatches nothing. #355's candidates: 0 cannot separate that from a workspace with no ready work — it is the same blind spot one layer down.

The signal, and why it is a pair

readinessReconcile now publishes treeReads and emptyTreeReads beside candidates, plus a relayfileEmptyTreeReads counter.

Deliberately two numbers, not one. My first version was a bare empty-read counter, and its own control test caught it firing on a perfectly healthy sweep: discovery lists two path forms per repo and only one of them exists, so an empty read is ordinary. A counter that alarms on every normal sweep is worse than no counter. What is not ordinary is the ratio:

reading diagnosis
candidates: 0, treeReads: 3, emptyTreeReads: 1 empty workspace — normal
candidates: 0, treeReads: 3, emptyTreeReads: 3 mount serving nothing

Both published unconditionally, including zeroes, for the same reason dispatchFailures is: the comparison is the signal, and an omitted zero would make "served real content" indistinguishable from "field not reported".

The two assertion gaps from your #351 review

You asked me to assert these; #354 inferred them from "the next cycle ran".

  • Discovery lease released by the unwind — now asserted directly by taking it with a different owner after the abort. #runOnceWithDiscoveryFence's finally is the only releaseDiscoverySweep call site and stop() has none, so a lease free there was freed by the aborted sweep.
  • #runOnceInFlight cleared — now asserted by requiring a second independent failure while the dependency is still hung. Had the field still held the wedged promise, cycle two would have coalesced onto it and never settled, pinning consecutiveFailures at 1 forever.

Still open, your call

lastError names operation and phase but not path/prefix, which you asked for. I left it out because lastError is persisted and returned from status(), and #297's precedent builds that string only from closed-set tokens — a workspace path is dependency-shaped text on an operator surface. The path is in the [factory] relayfile operation failed log line. Say the word and I will add it; I should have flagged the deviation on #354 rather than making it silently.

Verification

src/orchestrator/factory.test.ts: 586/586 locally. Ablation: disabling the empty-read increment fails the fault test on the count; reducing the signal to a bare count fails the control.

One thing worth flagging — two unrelated tests failed downstream of my first draft and they were mine, not the substrate. The new live-mode fixtures wrote to the config's shared default registry path and leaked rows into whatever ran next, breaking a Slack-coalescing and a babysitter test. I caught it by running those two against a clean base rather than assuming an unrelated failure was #342. They now get an isolated mkdtemp registry, heartbeat and state store.

🤖 Generated with Claude Code


Summary by cubic

Distinguishes an empty-serving mount from a timeout in orchestrator readiness (Linear 351). Previously, a mount that always returned empty trees looked healthy and identical to a workspace with no ready work; now the status exposes why candidates: 0 occurs.

  • Adds treeReads and emptyTreeReads to readinessReconcile and IterationReport. Counts are per-sweep, read before reset, and published even when zero. Increments relayfileEmptyTreeReads.
  • Treats the ratio as the signal: a silent mount is treeReads > 0 and emptyTreeReads === treeReads.
  • Asserts two review points: the discovery lease is released on abort, and #runOnceInFlight clears (proven by a second independent failure).
  • Updates docs/deployed-diagnostics.md to document interpretation.
  • Isolates live-mode test fixtures with mkdtemp registry/heartbeat to prevent cross-test leakage.

Rollout

  • No migrations. Clients of status() will see optional treeReads and emptyTreeReads.
  • Update monitoring to flag silent mounts: alert on treeReads > 0 && emptyTreeReads === treeReads && candidates === 0. Consider tracking relayfileEmptyTreeReads.

Written for commit 379456a. Summary will update on new commits.

Review in cubic

…timing out (#351)

Follow-up to #354, which shipped without a requirement asked for twice on #351.

#354 made a hung relayfile read loud: a per-call deadline, a named
`RelayfileOperationTimeoutError`, a rising `consecutiveFailures`. It left the
other half open. A mount that starts answering reads with *nothing* instead of
hanging raises no timeout, no failure and no `lastError` — the sweep completes
`healthy` and dispatches nothing, and on every field the surface publishes that
is indistinguishable from a workspace with no ready work. #355's `candidates: 0`
cannot separate them either; it is the same observation one layer down.

So a sweep now reports both the tree reads the backend served and how many came
back empty, on `readinessReconcile` beside `candidates` and as the
`relayfileEmptyTreeReads` counter.

Deliberately a pair, not a count. The first version of this was a bare
empty-read counter and its own control test caught it firing on a healthy sweep:
discovery lists two path forms per repo and only one of them exists, so an empty
read is ordinary. What is not ordinary is `emptyTreeReads === treeReads` with
`treeReads > 0` — the mount served nothing at all. `candidates: 0, treeReads: 3,
emptyTreeReads: 1` is an empty workspace; `emptyTreeReads: 3` is a silent mount.

Also closes two smaller gaps from the #351 review that #354 answered by
inference rather than assertion:

- The discovery lease released by the unwind is now asserted directly, by taking
  it with a different owner after the abort. `#runOnceWithDiscoveryFence`'s
  `finally` is the only `releaseDiscoverySweep` call site and `stop()` has none,
  so a lease free there was freed by the aborted sweep.
- `#runOnceInFlight` clearing is now asserted by requiring a SECOND independent
  failure while the dependency is still hung. Had the field still held the
  wedged promise, cycle two would have coalesced onto it and never settled,
  pinning `consecutiveFailures` at 1.

The new fixtures get an isolated mkdtemp registry, heartbeat and state store.
The config default points every instance at one shared path, and without that
isolation these two live-mode factories leaked rows into whatever ran next --
two unrelated Slack/babysitter tests failed downstream. Caught by comparing
against a clean base rather than assuming an unrelated failure was the substrate.

Verified by ablation: disabling the empty-read increment fails the new fault
test on the count, and the control fails if the signal is reduced to a bare
count.

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

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 379456a701b13bfa189042965f3c0d2154263e64.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 14 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: 1c2f12a8-4f40-4045-a8d5-07dad319c9c4

📥 Commits

Reviewing files that changed from the base of the PR and between 2f32e69 and 379456a.

📒 Files selected for processing (4)
  • docs/deployed-diagnostics.md
  • 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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 379456a701

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +5259 to +5260
treeReads: this.#readinessReconcileLastSweep.treeReads,
emptyTreeReads: this.#readinessReconcileLastSweep.emptyTreeReads,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Expose the pair through deployed diagnostics

On deployed instances using the documented unauthenticated /healthz route, these fields never appear: publicHealthFromHeartbeat() passes readiness data through sweepOutcome(), whose whitelist and FactoryPublicReadinessReconcileHealth omit both fields, and normalizePublicHealth() consequently strips them as well. Thus the default factory diagnose --deployed path still cannot distinguish an empty mount from an empty workspace, even though the internal status() object contains the new values; carry and validate both counts through the public-health projection.

Useful? React with 👍 / 👎.

Comment on lines +4405 to +4409
if (count !== undefined) {
this.#discoverySweepTreeReads += 1
if (count === 0) {
this.#increment('relayfileEmptyTreeReads')
this.#discoverySweepEmptyTreeReads += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope tree-read counters to the active readiness sweep

In live mode, event drains and completion timers can call #listRelayfileTree concurrently with runOnce(), and this unconditional increment counts every successful list operation in the process rather than only calls issued by the readiness sweep. A populated PR/Slack lookup overlapping an otherwise all-empty discovery pass makes emptyTreeReads < treeReads and masks the silent-mount signal; conversely, unrelated empty lookups can distort the ratio. Track these counts in sweep-local context or increment them only at enumeration call sites.

Useful? React with 👍 / 👎.

@khaliqgant

Copy link
Copy Markdown
Member Author

CI green on all five jobs (run 32698619106): package, load-e2e, verification-stack-e2e, kubernetes-provider-e2e, verification-gate-e2e.

package: 2121 passed, 1 skipped, 0 failed. That is the number that matters here beyond the new tests — the fixture-isolation fix is what keeps it there. Before it, two unrelated tests (Slack coalescing, babysitter waking) failed downstream of my live-mode factories writing to the config's shared default registry path.

Ready to merge. The one open decision is lastError path/prefix, described in the PR body — deliberately left out, and yours to call.

@khaliqgant

Copy link
Copy Markdown
Member Author

Both threads block, and the P1 is the third occurrence of the same systemic defect. Worth naming it as such.

P1 at factory.ts:5260 — a second whitelist, one layer up

publicHealthFromHeartbeat() routes readiness through sweepOutcome(), whose whitelist drops the new empty/timeout pair, so on deployed instances the fields never appear.

This is the third time tonight a field has been published and then silently discarded by a projection allowlist:

  1. fc#73subsystemDetail() dropped candidates/dispatched/skipped. Cost: hours, because an absent field is indistinguishable from a daemon not producing it.
  2. fc#76 — the same function dropped dispatchFailureReasons and lastErrorClass after 0.1.73 added them. Caught only because unprojectedKeyCount existed.
  3. ThissweepOutcome() in factory itself, upstream of both.

So a field must now survive two independent whitelists to reach an operator, and neither announces what it drops. That is a structural trap: every future health field is silently broken by default, and the only reason we caught the last two was a drift counter added as a cheap afterthought.

Fix the immediate case: forward the pair through sweepOutcome(), numbers only, zero distinguishable from absent — same treatment as the counters. Then file a follow-up proposing a drop-detector on the factory side mirroring unprojectedKeyCount, so this stops depending on a reviewer noticing.

Note the P1 says the fields are missing from the documented unauthenticated /healthz. Be careful there: /healthz is served without a bearer and must stay .state-only for the subsystem blocks. If the pair is genuinely useful to an unauthenticated liveness check, argue it explicitly; otherwise route it to the authenticated /evidence path and correct the doc instead.

P2 at factory.ts:4409 — the counter counts the wrong population

In live mode, event drains and completion timers call #listRelayfileTree concurrently with runOnce(), so an unconditional increment counts every successful list, not just those in the active readiness sweep.

That matters more than a typical counter bug because this counter is evidence. An inflated tree-read count next to an empty/timeout verdict would make a mount look busier than the sweep actually found it, and I would read it as "reads are happening, so the mount is fine" — the exact wrong conclusion. Scope it to the active sweep.

Context

main is green and 0.1.73 is deployed. fc#76 just merged and is deploying, which forwards dispatchFailureReasons through the factory-cloud side. Your pair needs the same treatment on the factory side or it will be invisible for the same reason.

Not on the immediate critical path — that is the pending dispatch reading — so take the round trip and do both properly. Per-job green and I merge.

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