Skip to content

fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep - #368

Merged
khaliqgant merged 1 commit into
mainfrom
fix/bound-relayfile-event-feed
Aug 25, 2026
Merged

fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep#368
khaliqgant merged 1 commit into
mainfrom
fix/bound-relayfile-event-feed

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 25, 2026

Copy link
Copy Markdown
Member

Do not merge — factory-lead holds the gate

This is the deployed dispatch wedge. #354 bounded every relayfile read except the two
the discovery sweep makes first: the change-log tail reads.

The defect

src/mount/relayfile-cloud-mount-client.ts, before this change:

1016  async getEvents(opts) {
1018    const response = await this.#client.listLastNChanges(opts.last, { workspaceId: this.workspaceId })
1027    const response = await this.#client.getEvents(this.workspaceId, opts)

1034  async getEventHighWatermark(opts = {}) {
1036    const response = await this.#client.listLastNChanges(10, { workspaceId: this.workspaceId })

No #bounded, no relayfileCallDeadline, no withRelayfileCallDeadline, no
AbortSignal. Per mount/relayfile-operation-timeout.ts:3-8, the SDK "attaches an
AbortSignal to its fetch only when the caller supplies one" — so each of these is a
bare fetch() that can wait forever. grep -c '#bounded(' on the shipped
dist/mount/relayfile-cloud-mount-client.js in @agent-relay/factory@0.1.74 returns
5: readFile, writeFile.readRevision, deleteFile.readCurrent, ensureSubRoot
— plus listTree using the deadline primitives directly. The event feed was missed.

Why this is the wedge, not a theoretical hole

#prepareDiscoverySession (orchestrator/factory.ts:3860) calls
#discoveryHighWatermark() (:3865:3958) → mount.getEventHighWatermark() as the
first network read after #runOnceWithDiscoveryFence claims the discovery lease, and
before #performRunOnce logs anything at all.

Measured on the live instance (@agent-relay/factory@0.1.74, boot 2026-08-25T06:13:07Z,
deploy run 32815029701 / e19dcf4):

  • The sweep that started 06:17:21.603Z was still in flight at 06:41:18Z
    inFlightMs: 1426351 (23.8 min), missedPasses: 23, consecutiveFailures: 0.
  • Over the 15.7 minutes of the daemon's own output ring (via /evidence, Release-on-question, wake-on-reply: don't hold an agent's session while a human answers #77) there is
    no run-once started, no ready-issue read progress, and every listTree
    carries phase: "dependency PR probe resolution" — the live event drain — never a
    discovery phase. The sweep never reached enumeration.
  • 23.8 minutes is far past the 5-minute budget every #bounded call already carries
    (DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS, no config override), so the await it was held
    on had to be one outside that wrapper. There are exactly two, and they are both here.

The ~4.3-minutes-after-every-boot signature follows from
DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000 (factory.ts:506): the dead container's lease
outlives its process, so the first couple of sweeps return discoveryDeferred: "sweep-in-flight" in lastDurationMs: 735, and the pass that finally reclaims the lease
is the one that hangs.

Full await audit, and the build-identity proof, on
factory-cloud#55.

THIS IS A DEADLINE, NOT A CANCELLATION — read this before extending it

Say it plainly so nobody later mistakes one for the other:

  • getEvents (cursor-paged) gets real cancellation. GetEventsOptions carries a
    signal, so the abort reaches the transport, same as listTree.
  • listLastNChanges does not, and cannot. Its ProactiveRequestContext is
    { workspaceId, token? } — there is no signal field to pass. It is bounded by
    withRelayfileCallDeadline's race instead: the abandoned-wait backstop that module
    documents for "mount implementations that cannot honour a signal". That is genuinely
    weaker
    than listTree's cancellation — the socket and the SDK's own retry loop stay
    live, and a read the SDK has cached as in-flight is not torn down.

The half that matters here is the other one. The rejection unwinds the sweep, which
releases the discovery lease, so the next cycle starts clean instead of coalescing onto a
wedged runOnce(). That coalescing is the whole reason the existing 90-minute sweep
deadline cannot recover this: #runOnceWithReadinessDeadline (factory.ts:2200) rejects
the wait and leaves runOnce() running, and runOnce() (:2988) hands the next pass
the same wedged #runOnceInFlight promise — which is the mechanism behind
discoveryDeferred: "sweep-in-flight" and behind an occupant reaching 4.14 days.
consecutiveFailures can therefore rise at most 1 per 90 minutes, so
failureThreshold: 3 is 4.5 hours away, and even degraded only reports —
isDeploymentHealthy still answers ok: true, so nothing replaces the container.

Getting real cancellation for the tail read needs a signal on the SDK's
ProactiveRequestContext; that is an upstream @relayfile/sdk change and is deliberately
not in this PR.

Tests — must-fire and must-not-fire for each

Must-fire (25 ms budget, change feed that never answers):

test asserts
bounds the discovery high-watermark read that wedged the sweep getEventHighWatermark() rejects RelayfileOperationTimeoutError, operation: 'listLastNChanges', timeoutMs: 25
bounds the change-log tail read behind a provider-filtered getEvents getEvents({ last }) rejects the same way
cancels a cursor-paged getEvents at the transport getEvents({ cursor }) rejects and client.seenSignal.aborted === true

Verified fail-first, by reverting only the source file and re-running: all three fail,
and they fail by Error: Test timed out in 5000ms — the call never returns. That
failure mode is the production defect, not an assertion detail.

× bounds the discovery high-watermark read that wedged the sweep        5010ms
× bounds the change-log tail read behind a provider-filtered getEvents  5016ms
× cancels a cursor-paged getEvents at the transport                     5017ms
Error: Test timed out in 5000ms.

Must-not-fire — so the bound cannot be satisfied by deleting it, and so the rejections
above are attributable to the budget rather than to the wrapper:

test asserts
leaves a served change-feed read alone under a generous budget 60 s budget: a healthy read still returns '11' and still makes exactly one call, { limit: 10, context: { workspaceId: 'rw_test' } }
leaves the change-feed read unbounded when no budget is configured operationTimeoutMs: 0: the hung read is still 'pending' after 50 ms

Both pass with and without the change, which is what makes them controls.

One pre-existing assertion moved with the behaviour:
delegates readFile/listTree/getEvents with the configured workspace id now expects the
getEvents options to carry signal: expect.any(AbortSignal) with aborted === false,
exactly as its listTree sibling already did.

Local: npx vitest run src/mount/relayfile-cloud-mount-client.test.ts79 passed,
exit 0
. npx tsc --noEmit -p tsconfig.build.jsonexit 0.

Deliberately NOT in this PR

Two separate defects found while diagnosing, both written up on factory-cloud#55:

  1. confirmWrite's getOp loop (relayfile-cloud-mount-client.ts:1053-1058) —
    const deadline = Date.now() + 90_000; for(;;) { await this.#client.getOp(...) }. The
    deadline is evaluated between calls, so it can never interrupt one. Same class of
    bug, different call path, not on the reconcile path.

  2. The dead-host release loop. /evidence shows three agents retried every second,
    forever, against RelayError 503 rawCode=agent_host_unavailable retryable=true, on a
    node whose lastHeartbeatAt is 2.9 days old. 27 identical cycles in 15.7 minutes,
    zero progress. It is generating essentially all of the daemon's log volume. It is
    timer-driven and does not block the sweep, so it is not this outage — but a
    permanently-dead host node needs a cap or a terminal classification, the way fix(factory): treat 404 agent_not_found on release as terminal success #365
    terminalised a 404.

  3. #runOnceWithDiscoveryFence's Relayfile-overload backoff (factory.ts:3049,
    await this.#clock.sleep(delayMs)) is unbounded by construction. Not the culprit here
    — it logs a warn that would still be in the ring — but it is a real third hole.

Reported by wedge-layer2 to factory-lead.


Summary by cubic

Bounds the relayfile change feed reads so a hung tail read can’t wedge the discovery sweep. Previously getEvents and getEventHighWatermark issued uncancelled, unbounded calls; now they respect the per-call timeout and unwind the sweep on timeout instead of hanging indefinitely.

  • getEvents runs through the existing #bounded helper and passes a signal so the SDK can cancel in-flight requests.
  • listLastNChanges is wrapped in a new #boundedListLastNChanges helper; it’s deadline-bounded via a race (no transport cancel in the SDK context).
  • Tests add must-fire timeouts and must-not-fire controls; the delegation test now expects signal: AbortSignal on change-feed calls.
  • No config change required; operationTimeoutMs: 0 keeps the feed unbounded, and healthy reads under generous budgets are unaffected.

Written for commit 71e5c87. Summary will update on new commits.

Review in cubic

…t wedge the sweep

#354 bounded every relayfile read except the two the discovery sweep makes
first. `getEventHighWatermark()` and `getEvents()` went straight to the SDK
with no deadline and no signal, and per `relayfile-operation-timeout.ts` the
SDK attaches an `AbortSignal` to its `fetch` only when the caller supplies
one — so both were bare `fetch()` calls that can wait forever.

That is the deployed wedge. `#prepareDiscoverySession` calls
`#discoveryHighWatermark()` -> `mount.getEventHighWatermark()` as the FIRST
network read after `#runOnceWithDiscoveryFence` claims the discovery lease,
before `#performRunOnce` logs anything. On the live instance (0.1.74, boot
2026-08-25T06:13:07Z) the sweep that started 06:17:21.603Z was still in
flight 24 minutes later with `consecutiveFailures: 0`, and the daemon's own
output ring over that window contains no `run-once started`, no ready-issue
read progress, and no discovery-phase `listTree` — only the live event
drain's. The sweep never reached enumeration, and 24 minutes is well past
the 5-minute budget every `#bounded` call already carries, so the await it
was held on had to be one outside that wrapper. There are exactly two.

The sweep's own 90-minute deadline cannot substitute. It rejects the wait
and leaves `runOnce()` running, so the next cycle coalesces onto the same
wedged promise; a per-call rejection unwinds the pass and releases the
discovery lease instead.

Both feed methods now go through the existing `#bounded()` helper. The
cursor-paged `getEvents` gets real cancellation — `GetEventsOptions` carries
a `signal`. `listLastNChanges` cannot: its `ProactiveRequestContext` has no
signal field, so it is bounded by `withRelayfileCallDeadline`'s race, the
abandoned-wait backstop that module documents for exactly this case. Weaker
(the socket stays live) but it is the half that matters: the rejection
unwinds the sweep.

Tests, must-fire and must-not-fire for each:
- must-fire: with a 25 ms budget and a change feed that never answers,
  `getEventHighWatermark()`, `getEvents({ last })` and `getEvents({ cursor })`
  each reject as `RelayfileOperationTimeoutError` naming the operation, and
  the cursor path's signal is `aborted`. Verified fail-first: with the fix
  reverted all three fail by TIMING OUT at vitest's 5 s default — the
  production mechanism, not an assertion detail.
- must-not-fire: a served read under a 60 s budget still returns its
  watermark and still makes exactly one call; with `operationTimeoutMs: 0`
  the hung read stays pending, so the rejections above are attributable to
  the budget and not to the wrapper.

Not fixed here, and filed as separate defects on factory-cloud#55:
`confirmWrite`'s `getOp` loop checks its deadline BETWEEN calls, so it can
never interrupt one; and a `503 agent_host_unavailable` release against a
host node offline for days is retried every second forever.

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

Session-Id: 6534f313-3c75-412b-bfd4-6ac9b59b9405
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai review

Requested for exact head 71e5c87718f295afc639b29bdc6876384bc54141.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3021c564-e1c8-417a-a29b-6d3323321d1e

📥 Commits

Reviewing files that changed from the base of the PR and between bffa2da and 71e5c87.

📒 Files selected for processing (2)
  • src/mount/relayfile-cloud-mount-client.test.ts
  • src/mount/relayfile-cloud-mount-client.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change-feed client now applies operation deadlines to tail, high-watermark, and cursor reads. Tests cover signal propagation, timeout handling, transport cancellation, successful reads, and unbounded operation.

Changes

Change-feed deadline handling

Layer / File(s) Summary
Bound change-feed operations
src/mount/relayfile-cloud-mount-client.ts
Tail and high-watermark reads use a bounded listLastNChanges helper. Cursor reads use bounded getEvents calls with an abort signal.
Validate deadline behavior
src/mount/relayfile-cloud-mount-client.test.ts
Tests cover timeout errors, signal propagation, transport cancellation, successful reads, and disabled deadlines.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 71e5c

This localized change bounds change-feed reads and includes passing targeted tests and type checking; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: miyaontherelay, kjgbot

Poem

A rabbit watched the deadlines flow

Tail reads bounded, signals glow
Cursors stop when time is through
Wide budgets let the events run true
No timeout? They wait anew

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: bounding Relayfile change-feed reads to prevent a hung tail read from blocking the readiness sweep.
Description check ✅ Passed The description directly explains the defect, implementation, tests, observed behavior, and intentionally deferred issues. It is fully related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bound-relayfile-event-feed

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.

@khaliqgant

Copy link
Copy Markdown
Member Author

Corrections to two things said off my working tree — both matter operationally

1. Your reading of the change is right. #boundedListLastNChanges covers both call
sites (getEvents() when opts.last is set, and getEventHighWatermark()), and
getEvents()'s cursor path is bounded separately with a real transport signal. #351/#354
bounded listTree and missed the change-log tail. Nothing to correct there.

2. factory-cloud#56 does NOT put anything on /healthz, and it does not report a
build id.
"Just read /healthz after the next boot" will not work.

a8c0772 puts dispatchContract on the authenticated /evidence only, and
container/entrypoint.mjs says so explicitly in the code it added:

// AUTHENTICATED only. See container/dispatch-contract.mjs for why the
// repo list must never join the public `/healthz` projection above.
dispatchContract: await readDispatchContract(),

Confirmed empirically. The #56 deploy rebooted the container at 06:54:03.285Z; I
sampled /healthz at 06:56:05Z and it carries exactly the same top-level keys as
before — ok, phase, factoryProcess, heartbeat — and inside heartbeat only
status, source, startedAt, startedAtMs, progressContract, updatedAt,
updatedAtMs, eventListener, readinessReconcile, health. No dispatchContract, no
version, no commit. (Field presence is a projection fact, not a timing one, so the 2-minute
uptime does not weaken this — but every value in that sample is worthless and I am not
using any.)

And container/dispatch-contract.mjs's projection returns issueSource, batchSize,
mergePolicy, terminalState, repos{org,cloneRoot,count,names}, safety, github,
reportingno package version, no commit, no image id. #56 answers "which repos and
labels is this deployment scanning?", which is genuinely valuable and was worth deploying.
It does not answer "which build is serving?".

So build identity still rests on the chain I put on
factory-cloud#55:
deploy run 32815029701headSha e19dcf4factory-version.json: 0.1.74
Dockerfile:34-35 installs exactly that pin → deploy log ok: image will install @agent-relay/factory@0.1.74 and ok: container/factory.mjs resolves against @agent-relay/factory@0.1.74 → boot 06:13:07Z inside the run window. Strong, but still a
chain. The durable instrument would be a one-line factory-cloud change: put the resolved
@agent-relay/factory version on the health projection.
It is not secret — the version
is in a public npm registry and in a public repo's factory-version.json — so unlike the
repo list it can safely sit on /healthz. I have not opened that PR; say the word and I
will.

Also worth recording, since it is the trap I nearly fell into: on factory-cloud#55 I show
that #363 IS in published 0.1.74 despite git log ordering the release commit before
it
— the publish job builds from the tip of main at run time (npm publish
05:57:19.681Z, #363 commit 05:50:04Z), and the 0.1.74 tarball's dist contains all
three of #363's commits including the public-health.js carry-through. The absent
treeReads/emptyTreeReads on /healthz are benign: the pair rides with the candidates
trio, and no sweep on that boot ever completed an enumerating pass.

Live evidence has reset

The #56 deploy replaced the container, so the 06:13:07Z wedge I diagnosed is gone and the
new boot is at 06:54:03.285Z. Per DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000, if this
change is the right one the next wedge should appear ~4 min in, at roughly 06:58–06:59Z
— on a build that still has the unbounded tail read. That is a prediction this PR can be
judged against, and it costs nothing to check.

@khaliqgant

Copy link
Copy Markdown
Member Author

Three notes on the production evidence read

1. The mount is healthy. This PR is not a claim that it is broken — say so explicitly.

listTree returns in 89–120 ms, ensureSubRoot returns in 318 ms, the workspace mirror
preflight reports mounted=1 failed=0 routedRepos=21, and live GitHub issue events are
arriving and draining at 35–239 ms latency. Same picture in the 06:13 boot's output ring:
four listTree completions at 541–2150 ms while the sweep was 20 minutes into its hang.

The transport is fine. One specific pair of calls on it never returns while its siblings
return in milliseconds
— and those two are precisely the ones with no deadline attached.
The completing-operations log is itself corroboration: every relayfile operation that
appears in it (listTree, ensureSubRoot, readFile) is one #354 bounded, and the two
that never appear are the two #354 missed.

2. readinessReconcile.lastError will be ABSENT, and that is not a refutation.

Worth saying before the next /evidence read comes back, so its absence is not read as
evidence against this fix. lastError is written in #reconcileReadyIssues's catch
(orchestrator/factory.ts:2288+). A hang takes neither the success nor the failure path,
so nothing populates it until something rejects. On the current build the only thing that
can reject a wedged pass is the 90-minute sweep deadline
(DEFAULT_READINESS_RECONCILE_TIMEOUT_MS), so:

on a wedge that starts at T, lastError stays absent until T + 90 min.

For the 06:13 boot that was 07:47:21Z. An /evidence read at 07:00 or 07:15 will show
state: stalled, consecutiveFailures: 0, and no lastError — exactly as the read at
06:41:56Z did. That absence is the signature of an unbounded await, not an argument
against one.

The corollary is the reason this PR matters: after it lands, that same wedge produces
lastError: "relayfile listLastNChanges did not respond within 300000ms" in five minutes
instead of nothing in ninety
— and, unlike the sweep deadline, it unwinds the pass and
releases the discovery lease so the next cycle starts clean.

3. One correction: the missing treeReads/emptyTreeReads is neither case (a) nor case
(b). It is benign.

Your caveat is the right one, and it is already settled — I checked it against the
published artefact rather than the working tree. #363's /healthz carry-through IS in
0.1.74: npm pack @agent-relay/factory@0.1.74 and the tarball's
dist/orchestrator/public-health.js:225-231 contains treeReadOutcome() in full, plus
dist/orchestrator/factory.js:4180 (relayfileEmptyTreeReads) and :395
(discoveryEnumerationPass = new AsyncLocalStorage(), the CodeRabbit per-call-context
fix). sweepOutcome()/normalizePublicHealth() did not drop it.

The fields are absent for the reason #363's own comment gives at
public-health.js:227-231 — "a rejected trio takes the pair with it, at sweepOutcome's
early return". The pair rides with the candidates trio, and the trio is absent because no
sweep has completed an enumerating pass on this boot. Consistent with that: /healthz
publishes no candidates, no dispatched and no skipped either. So #363 shipped and
works; there is nothing to chase there.

(The trap worth recording: git log orders the 0.1.74 release commit 42a6f66 before
#363 (0f65839), and git show 42a6f66:src/types.ts | grep -c emptyTreeReads returns 0 —
which reads as "#363 is unreleased". It is wrong. The publish job builds from the tip of
main at run time: npm published 0.1.74 at 05:57:19.681Z, after #363's 05:50:04Z
commit. Reading the tarball settles in seconds what the log implies incorrectly.)

4. batchSize: 2 changes nothing here. Nothing in this diagnosis used it — /healthz
has been publishing dispatchCapacity.batchSize: 2 throughout, and the wedge is upstream
of capacity entirely: discoveryDeferred: "sweep-in-flight" means discovery never ran, so
there was never anything to place into either slot.

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

No issues found across 2 files

Re-trigger cubic

@khaliqgant

Copy link
Copy Markdown
Member Author

CI green at this PR's head — and a prediction that came true in production

$ gh run list --repo AgentWorkforce/factory --branch fix/bound-relayfile-event-feed \
    --json databaseId,name,status,conclusion,headSha
runlist exit=0

CI                        completed  success  71e5c87718f295afc639b29bdc6876384bc54141
                          https://github.com/AgentWorkforce/factory/actions/runs/32819121661
Request CodeRabbit review completed  success  71e5c87718f295afc639b29bdc6876384bc54141
                          https://github.com/AgentWorkforce/factory/actions/runs/32819121568
  • gh api /repos/AgentWorkforce/factory/pulls/368 -q .head.sha71e5c87718f295afc639b29bdc6876384bc54141
  • git rev-parse HEAD (pushed) → 71e5c87718f295afc639b29bdc6876384bc54141
  • CI run 32819121661 headSha71e5c87718f295afc639b29bdc6876384bc54141

All three equal. mergeable_state: clean. Result read by exit code, not by absence of
error; the list is non-empty.

Not a review. The green Request CodeRabbit review run went green because the request
posted, per the standing rule. No bot has reviewed this head yet. I will answer every
thread that arrives.


The wedge reappeared on the new boot, on schedule

I predicted in the first comment
that the #56 boot would wedge ~4 min in, because the dead container's discovery lease has
to expire first (DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000) and only the pass that reclaims
it does real work. Spaced /healthz samples, boot 06:54:03.285Z:

06:57:22Z  rr=healthy  inFlightMs=None   missed=None  cf=0  lastDur=1682  deferred=sweep-in-flight
06:58:53Z  rr=healthy  inFlightMs=None   missed=None  cf=0  lastDur=719   deferred=sweep-in-flight
07:00:24Z  rr=healthy  inFlightMs=58818  missed=0     cf=0  lastDur=719   deferred=sweep-in-flight

Two sweeps complete cheaply (719 ms, 1682 ms) while the old lease is still held — both
returning discoveryDeferred: "sweep-in-flight", which is #runOnceWithDiscoveryFence's
lease-not-acquired return (orchestrator/factory.ts:3066-3073), not a coalesce. Then at
~06:59:25Z a pass claims the lease, and it has not returned since. Same signature, third
boot running: consecutiveFailures: 0, in-flight advancing exactly with wall clock.

Two things that follow, both worth having on the record before the next /evidence read:

  1. The wedge is not caused by anything feat(factory): [SHADOW] escalate to GitHub issue comment when Slack channel absent (#55) #56 changed — it reproduces identically on a
    build that has config: expand ~ in cloneRoot/clonePaths, and default clonePath to cwd for single-repo local runs #78, feat(factory): [SHADOW] escalate to GitHub issue comment when Slack channel absent (#55) #56 and 0.1.74, which is every layer fixed so far.
  2. lastError will be absent until 08:29Z — 90 minutes after 06:59:25Z, per the
    sweep deadline. As set out in the previous comment, that absence is the signature of an
    unbounded await, not evidence against one. After this PR lands, the same wedge names
    itself in five minutes:
    relayfile listLastNChanges did not respond within 300000ms.

Ready for the gate. Do not merge — factory-lead holds it.

@khaliqgant
khaliqgant merged commit 423e293 into main Aug 25, 2026
9 checks passed
@khaliqgant
khaliqgant deleted the fix/bound-relayfile-event-feed branch August 25, 2026 07:02
@khaliqgant

Copy link
Copy Markdown
Member Author

The wedge, measured for 10 minutes on the post-#56 boot

Completing the sampler from the previous comment. Boot 06:54:03.285Z, build carries
#78 + #56 + @agent-relay/factory@0.1.74 — every layer fixed so far.

06:57:22Z  rr=healthy  inFlight=None      missed=None  cf=0  lastDur=1682  deferred=sweep-in-flight
06:58:53Z  rr=healthy  inFlight=None      missed=None  cf=0  lastDur=719   deferred=sweep-in-flight
07:00:24Z  rr=healthy  inFlight=58818     missed=0     cf=0  lastDur=719   deferred=sweep-in-flight
07:01:54Z  rr=healthy  inFlight=149103    missed=2     cf=0  lastDur=719   deferred=sweep-in-flight
07:03:25Z  rr=healthy  inFlight=239263    missed=3     cf=0  lastDur=719   deferred=sweep-in-flight
07:04:56Z  rr=healthy  inFlight=329311    missed=5     cf=0  lastDur=719   deferred=sweep-in-flight
07:06:27Z  rr=healthy  inFlight=419342    missed=6     cf=0  lastDur=719   deferred=sweep-in-flight
07:07:58Z  rr=healthy  inFlight=509387    missed=8     cf=0  lastDur=719   deferred=sweep-in-flight
07:09:29Z  rr=healthy  inFlight=599418    missed=9     cf=0  lastDur=719   deferred=sweep-in-flight

The wedged pass started at 06:59:25.2Z (07:00:24Z minus 58818 ms) — 5.4 min
after boot, which is DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000 plus the 60 s interval's
granularity, exactly as predicted.

Making "advances with wall clock" quantitative rather than asserted — per-sample deltas
against a ~90 s sampling period:

90285  90160  90048  90031  90045  90031  ms

Over the nine samples, in-flight advanced 540,600 ms against ~540.6 s of wall clock.
A one-to-one ratio with no drift, for ten minutes. That is not a slow pass making
progress; the pass is doing nothing at all. lastDurationMs is frozen at the 719 of the
last pass that completed, consecutiveFailures is pinned at 0, and state is still
healthy — it will not even read stalled until 10.5 min in.

Note the two cheap passes before it (1682 ms, 719 ms), both returning
discoveryDeferred: "sweep-in-flight". That is #runOnceWithDiscoveryFence's
lease-not-acquired early return (orchestrator/factory.ts:3066-3073), not a coalesce —
the dead container's lease had not yet expired, so those passes did no discovery work and
could not hang. Only the pass that reclaims the lease reaches the change-log tail read.
That is why the wedge is ~4–5 min after every boot rather than immediately, and it is the
same shape across all three boots observed today.

Nothing here changes the diagnosis or the fix; it removes the last bit of hand-waving from
the measurement. PR is green and unchanged at 71e5c877.

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