Skip to content

fix(actor): derive liveness from last activity, not last completed step — a child blocked mid-step was indistinguishable from a dead one - #1965

Open
wqymi wants to merge 4 commits into
mainfrom
fix/roster-liveness-dead-child
Open

fix(actor): derive liveness from last activity, not last completed step — a child blocked mid-step was indistinguishable from a dead one#1965
wqymi wants to merge 4 commits into
mainfrom
fix/roster-liveness-dead-child

Conversation

@wqymi

@wqymi wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this PR ships — two steps, kept as two commits

This PR does two things to the same function, in order, and the commits are kept separate
because the second one largely retires the first:

  1. 773edc5db — bound the unreliable signal. deriveLiveness placed unbounded trust
    in a running/pending row, so a dead child stayed routable forever. This adds
    DEFAULT_LIVENESS_ABANDON_MS so the claim expires. Correct on its own, and its revert
    probes stay attributable to it.
  2. 2375280de — replace the signal so the bound barely matters. The reason the bound had
    to be so lenient (30 minutes) is that the signal was step-grained: it could only see a
    completed step, so a child blocked mid-step looked exactly like a dead one. This reads
    last activity instead, which already exists in the database and is written today. With
    that, the turnCount === 0 special case and the 30-minute window both collapse into a
    single 10-minute bound.

Read step 2 as the real fix and step 1 as the stopgap it grew out of. Sections below are
labelled by step.


Why this matters more than a display nit

The orchestrator's fleet roster exists for exactly one purpose: make the
orchestrator route work to an existing child instead of creating a new one — that is
#1741's whole point. So a dead child displayed as progressing is worse than no roster
entry at all
: the orchestrator routes work into a session that can never answer, and
progressing additionally suppresses re-dispatch because something appears to already be
in flight. Every consumer of deriveLiveness inherits this: the roster, session list
(which groups progressing/stalled under the heading "In progress"), session status,
and the fleet table. #1741's route-to-existing mechanism is only as good as the liveness
signal it reads, so an over-optimistic signal weakens it directly.

stalled is not a safe fallback either. It also lives under "In progress" and it also
marks the child as routable; it only says "no recent turn", not "nobody is home".

Step 1 — the defect: an unbounded claim

deriveLiveness (src/actor/schema.ts) placed unbounded trust in a running/pending
row. Two shapes:

  • A never-started child reads progressing forever. if (actor.turnCount === 0) return "progressing" returned early and bypassed the staleness window entirely. The comment's
    intent is right — a queued or cold-starting first turn must not be misread as a stall —
    but it implemented that leniency as unbounded.
  • A started child that died reads stalled forever, i.e. still "in progress".

The only repair for a row whose owner died is ActorRegistry's orphan sweep
(registry.ts: UPDATE actor_registry SET status='idle', last_outcome='failure', last_error='orphaned: process restarted' WHERE status IN ('pending','running') AND instance_id != $instanceID). That sweep runs once, at process init, and only for rows
carrying a different instance_id
. Until it runs — and for any row it cannot reach — the
derivation is the only thing standing between a corpse and the router, and it asserted
progress. Same defect class as #1960's orphaned running tool parts: a persisted transient
whose repair lives on exactly one path.

Step 1 — the fix: bound the claim

One new constant and one guard, both in deriveLiveness:

export const DEFAULT_LIVENESS_ABANDON_MS = 30 * 60_000

if (now - (actor.turnCount === 0 ? actor.time.created : actor.lastTurnTime) > abandonMs) return "idle"
if (actor.turnCount === 0) return "progressing"   // leniency KEPT, now bounded

Reference clock per row shape: a started child's evidence of life is its last turn; a
never-started one has none, so its spawn time (time.created) is the only honest
reference — which is what B needed: a longer bound measured from spawn, not removal of the
leniency.

Why 30 minutes, picked the way DEFAULT_LIVENESS_STALL_MS's comment picks 90s:
updateTurn is a per-step heartbeat, so a child that has genuinely begun cannot go 30m
without bumping last_turn_time — that is an order of magnitude past the 5-minute
stuck-detection cutoff (STUCK_THRESHOLD_MS) and unreachable by a live turn. A child that
has not begun is waiting on the concurrency gate, which admits work continuously, so 30m
of zero admissions means its process is gone.

Past the bound the row reads idle — already documented as "finished with no recorded
outcome (or an unknown state)" — and idle is the one bucket no consumer treats as
routable or in-flight. Deliberate direction of error: past the bound we would rather
report a still-queued child as idle (worst case: the orchestrator creates a duplicate,
which is wasteful) than as progressing (worst case: work routed into a corpse and
re-dispatch suppressed, which is unrecoverable).

Nothing is written to the database and no new repair mechanism is introduced; this is
purely the read side becoming honest.

Superseded in part by step 2. The 30 minutes below, and the turnCount === 0
special case it protects, are both artefacts of a step-grained signal. Step 2 replaces
the signal with last activity and collapses both into a single 10-minute bound; the
reasoning kept here is what step 1 shipped, not the PR's final state. abandonMs is a 4th parameter with the same
override shape stallMs already has.

Step 1 — what I could NOT confirm, and the named follow-up

The report I worked from assumed the registry "never reconciles rows whose process is gone
— they never even became idle". Measured on the two real fixture rows, that is not
true
, and I am reporting it rather than fixing a defect that is not there. Both rows in
the isolated dev home read:

session mode status lastOutcome turnCount lastTurnTime lastError
ses_05c0af252ffeigpmiJbqaa8vUV ("Fix calc.py add() bug") peer idle failure 26 1785163372484 orphaned: process restarted
ses_05c08a1e9ffeFg2DEKBuhhBxuS ("Modernize docs/README.md install steps") peer idle failure 20 1785162766301 orphaned: process restarted

All six orphaned rows share time_completed = 1785163573647 — one sweep, at process init.
So the sweep works and reached exactly these rows; the roster that showed
stalled/progressing was rendered while they were still running under the then-live
instance, and reconciliation only came at the next process start. That reframes the
registry-side defect from "no reconciliation exists" to "reconciliation is bound to
process init, so it cannot see a row whose owner is the CURRENT instance
".

That residual half is genuinely larger than this PR:

FOLLOW-UP — per-actor liveness ownership. Peers run in-process (Actor.spawnPeer
runTurn fibers) and are registered with the parent's PROCESS_INSTANCE_ID, so
instance_id cannot distinguish a dead child from a live one inside one process. Actor
does not even expose instance_id (fromRow drops it), so no consumer can ask. Detecting
a within-instance dead actor needs a new ownership signal (a per-actor heartbeat or an
owning-fiber liveness probe) plus a reconcile pass at the read point, gated the way
#1960's sweep is gated (if ((yield* status.get(sessionID)).type !== "idle") return, main
slice only). That is a schema + lifecycle change and is deliberately not in this PR.
This PR ships the part that is correct on its own: the read side stops asserting progress
it cannot justify, which closes the routing hazard for both the pre-sweep window and any
row the sweep cannot reach.

Completing that follow-up — the other half is signal granularity, and step 2 supplies it.
As written above, the follow-up covers only ownership: peers share the parent's
PROCESS_INSTANCE_ID and Actor.fromRow drops instance_id, so no consumer can ask who owns
a row. That is still open and still needs a lifecycle change. But it is not the whole residual,
and the part it omits turned out to need no new schema at all: the reason the bound above had to
be 30 minutes is that last_turn_time is written only by the per-step heartbeat
(ActorRegistry.updateTurn, registry.ts:223, called once from prompt.ts:3322 after
step++), so the finest event liveness could observe was a completed step — and a single
legitimate step here can run 20+ minutes. MAX(part.time_updated) already records "the last
time anything succeeded" at per-API-call granularity and is written today
(session.sql.ts:68storage/schema.sql.ts:3; measured: 750,545 of 840,186 part rows have
time_updated > time_created). Reading that is the real fix, and it is what commit
2375280de in this PR does — which is why the bound is now 10 minutes and the
turnCount === 0 special case is gone. Ownership remains the open follow-up; granularity is
closed here.

Step 1 — current scope on main: this caveat was stale, and it inverted

An earlier revision of this description flagged that <active-sessions> and
listPeerChildren were not on main (#1741 open, head 8847d324e, zero grep hits) and
that the roster therefore could not be reached from here. #1741 has since merged
origin/main is now its merge commit 57ff02ed5 — so that caveat is false, and what it
implied is inverted: this is not a scope downgrade, it means the routing hazard this PR
fixes is live on main today.
Verified on origin/main blobs at 57ff02ed5:

  • src/session/llm.ts:36 imports deriveLiveness; :348 calls actorReg.listPeerChildren;
    :355 maps every child through deriveLiveness; :366 keeps exactly progressing and
    stalled as the routable "working" set; :377 renders that status into the injected
    roster line. The roster is on main and it reads this signal.
  • src/actor/schema.ts:81 on main still returns "progressing" unconditionally when
    turnCount === 0, and :82 bounds a started row by stallMs alone — deriveLiveness
    there takes no abandonMs at all. So a child that died before its first turn reads
    progressing forever, directly into the roster the orchestrator routes from.
  • The on-main tool consumers this PR asserts through are unchanged: tool/fleet.ts:97,
    tool/session.ts:697 and :967, which group progressing/stalled under "In progress"
    (tool/session.ts:983-984, tool/fleet.ts:146-147).

Two things follow from the merge that I am correcting rather than restating. The literal
<active-sessions> tag no longer exists anywhere on main#1741's final commit deleted
it — so the block is ROSTER_HEADER (session/llm.ts:73-76) and the prompt calls it your
"FLEET ROSTER" (orchestrator.txt:30); naming it by the old tag would now be wrong. And
while the roster is reachable, this PR adds no test at that injection point:
test/session/llm.test.ts and test/session/system.test.ts on main have zero hits for
deriveLiveness, ROSTER_HEADER or listPeerChildren, and I did not add one. The fix is
in the shared derivation, so the roster inherits it; what is directly asserted below is the
tool consumers.

Step 1 tests — one per defect, each revert-probed

test/actor/liveness.test.ts (+4) and test/tool/fleet.test.ts (+1). The fleet test uses
the two measured fixture rows' real field values (turnCount 26 / 20) replayed a day after
their last turn, and asserts through assembleFleet — the consumer — that neither lands in
a routable bucket.

Every pre-existing expectation is unchanged; the existing literals only gained the time
field the widened Pick now requires, and the existing "10-minute-old cold start is still
progressing" case still passes (10m < 30m), which is the point of keeping the leniency.

Probe 1 — restore the unbounded turnCount === 0 early return (defect B):

177 |     expect(deriveLiveness(stillborn, now)).not.toBe("progressing")
                                                     ^
error: expect(received).not.toBe(expected)
Expected: not "progressing"
(fail) deriveLiveness (T39 derivation rule) > never-started child spawned long ago does NOT read progressing [1.47ms]

137 |     expect(summary.counts.progressing).toBe(0)
                                             ^
error: expect(received).toBe(expected)
Expected: 0
Received: 1
(fail) assembleFleet > a row still claiming running a day after its last turn is not routable [7.93ms]

 21 pass  2 fail

Probe 2 — keep B's spawn bound, drop the bound for rows that HAVE run a turn (defect A):

218 |     const live = deriveLiveness(dead, now)
219 |     expect(live).not.toBe("stalled")
                           ^
error: expect(received).not.toBe(expected)
Expected: not "stalled"
(fail) deriveLiveness (T39 derivation rule) > started child still claiming running long after its last turn is not routable [0.34ms]

234 |     expect(deriveLiveness(row, now, DEFAULT_LIVENESS_STALL_MS, 5 * 60_000)).toBe("idle")
                                                                                  ^
error: expect(received).toBe(expected)
Expected: "idle"
Received: "stalled"
(fail) deriveLiveness (T39 derivation rule) > custom abandonMs overrides the default bound [2.02ms]

138 |     expect(summary.counts.stalled).toBe(0)
                                         ^
error: expect(received).toBe(expected)
Expected: 0
Received: 2
(fail) assembleFleet > a row still claiming running a day after its last turn is not routable [0.75ms]

 20 pass  3 fail

Step 1 suite numbers

bun typecheck exit 0.

Scoped run only (test/actor + everything that references the signal, found with
rg -l 'deriveLiveness|active-sessions|listPeerChildren' test), because concurrent
heavyweight suites on this host starve each other. Same command both sides
(bun test test/actor test/tool/fleet.test.ts test/tool/session-tool.test.ts --timeout 120000),
same worktree and node_modules, the three changed files reverted to origin/main for the
baseline:

pass skip fail tests files
pristine origin/main (60af8f1) 178 3 5 186 22
this branch 187 3 1 191 22

Note on the baseline commit: 60af8f1 predates the #1741 merge that is now main's tip
(57ff02ed5). These numbers are the record of that comparison run, not a claim about
today's main. The three files this PR changes are unchanged by that merge, which is why
the branch still applies cleanly.

+5 tests is exactly the five new cases. My failure set is a strict subset of the
baseline's: both runs fail spawn no-deadlock (F56) > checkpoint-writer settles (no hang) when session permission is '*':'ask' …, and the baseline additionally fails four
session tool join cases on their 30s timeouts. The runs were sequential rather than
side-by-side, so the four extra baseline failures are load-dependent flakes, not a
regression — none of them is touched by this change, and none appears on this branch.

Step 2 — the signal itself was too coarse

Mechanism

deriveLiveness's inputs all came from the actor registry row, and the only writer of
turn_count / last_turn_time is ActorRegistry.updateTurn
(packages/opencode/src/actor/registry.ts:223), called from exactly one place —
packages/opencode/src/session/prompt.ts:3322, immediately after step++, commented
"Per-step turn heartbeat: only writer of turn_count".

So the finest thing liveness could see was a completed step. A child blocked mid-step —
inside a long tool call, a slow model call, a retry/backoff — was indistinguishable from a
dead one. That is why the bounds had to be stretched: a single legitimate step in this repo
can run 20+ minutes (a live test run ≈1225 s; git worktree add >120 s), so any bound had to
clear the longest possible step, and the 30 minutes in step 1 existed only because the
signal was coarse.

The finer signal already exists and was simply not read. PartTable
(packages/opencode/src/session/session.sql.ts:68) spreads ...Timestamps, and Timestamps
(packages/opencode/src/storage/schema.sql.ts:3) declares
time_updated: integer().notNull().$onUpdate(() => Date.now()). Part rows are written when a
tool call starts and updated as it progresses, so MAX(part.time_updated) over an actor's
slice is already "the last time anything succeeded", at per-API-call granularity.

Verified that it is really written, rather than assumed — the projector writes parts with
onConflictDoUpdate, and it was not obvious that Drizzle's $onUpdate fires on that path.
Measured on a 5.4 GB production database:

parts total                     840186
part.time_updated > time_created 750545   (89%)
part.time_updated IS NULL             0

Deltas on individual rows (23644 ms, 8988 ms, 118 ms, 1 ms) show a single part row being
updated repeatedly while one tool call runs. Streaming token deltas do not hit the table
Session.updatePartDelta (packages/opencode/src/session/session.ts:771) only publishes a
bus event — so the write rate is bounded by discrete part lifecycle events plus
ctx.metadata() progress calls, not by tokens.

Shape: denormalised column, chosen by measurement

The seam to respect: part rows carry session_id but no agent id — the agent slice
lives on the message row — so a per-slice read needs a join to MessageTable, not a
single-table read.

The roster is rebuilt per request and iterates every peer child
(packages/opencode/src/session/llm.ts:348), so read-time cost is paid on a hot path. Measured
against the worst real roster on disk — a parent with 172 peer children and 45,377
parts
across them:

shape per roster build per part write
(a) read-time join, N+1 (one MAX per child) 172 queries — 25 ms warm / 487 ms cold 0
(a′) read-time join, single grouped query 1 query — 32–38 ms warm / 339 ms cold 0
(b) denormalised last_activity_time column 0 extra queries (rides in the row listPeerChildren already selects) 1 message-PK lookup + 1 registry-PK update, sub-millisecond

Batching does not rescue the read-time shape: (a′) is no better than (a) because the cost
is scanning the 45 k parts, not per-query overhead, and it grows without bound as children
accumulate history. So (b).

The writer is packages/opencode/src/session/projectors.ts:118 — the MessageV2.Event.PartUpdated
projector, which is the single writer of part rows and therefore already fires on every part
write. No new hook in the session loop. It is sequenced after the part insert inside the same
try, so activity is recorded only if the part actually landed, and it resolves the owning
actor through the message's primary key (session_id + message.agent_id), which hits
actor_registry's primary key directly and stays correct for peers, subagents and main
alike. For peers this is exact because runAgentLoop passes the actor id as the message's
agentID (packages/opencode/src/actor/spawn.ts:263).

The bound: 10 minutes, and why that number

export const DEFAULT_LIVENESS_ABANDON_MS = 10 * 60_000

const since = actor.lastActivityTime ?? actor.time.created
if (now - since > abandonMs) return "idle"
return now - since <= stallMs ? "progressing" : "stalled"

One reference for every row and no per-row special case. Measured over 43,120 real
inter-activity gaps
across that 172-child roster, and over the first-activity latency of all
172:

quantity value
inter-activity gap p50 994 ms
p90 6,740 ms
p99 38,048 ms
p99.9 296,811 ms (≈5 min)
first activity after spawn — p50 194 ms
first activity after spawn — max 1,735 ms
children with no activity at all 0 of 172

10 minutes is therefore:

  • STUCK_THRESHOLD_MS (packages/opencode/src/actor/registry.ts:16), the repo's own
    existing "stuck" cutoff — an anchor that already survived review, not a fresh magic number;
  • ~16× the measured p99 inter-activity gap and ~2× p99.9;
  • ~345× the worst measured first-activity latency. This is what licensed deleting the
    turnCount === 0 leniency rather than keeping it: the "slow first turn queued behind the
    concurrency gate" it protected is empirically under two seconds, because the user message
    that starts the turn is itself persisted as a part. Step 1's comment reasoned that a
    not-yet-begun child "is waiting on a continuously-admitting concurrency gate" and so needed
    30 minutes of grace; measurement says it needs about two.

Direction of error is unchanged and still deliberate: prefer a duplicate child (wasteful) over
routing into a corpse with re-dispatch suppressed (unrecoverable).

DEFAULT_LIVENESS_STALL_MS stays at 90 s. It is a display distinction only — stalled is
still routable — and against the activity signal it became more meaningful, not less: 90 s of
true silence is already past the 99th percentile.

turnCount is a counter again

It is no longer in deriveLiveness's Pick, so the compiler now prevents it being read as
evidence of life. updateTurn still writes it and turn-heartbeat.test.ts still pins that it
advances per step — that test needed no change, because the per-step heartbeat is still a real
thing, just not the liveness signal.

Two consequences fixed in the same commit, both cases of a number disagreeing with its own
predicate:

  • packages/opencode/src/actor/spawn.ts:940 reported the stall duration as
    now - actor.lastTurnTime while classifying on activity; it now reports the quantity the
    classification actually used.
  • packages/opencode/src/tool/fleet.ts:112 computed a column literally named lastActivityMs
    from lastTurnTime, i.e. from the last completed step; it now uses the same reference
    deriveLiveness classifies on, so the displayed age cannot contradict the displayed liveness.

Deliberately not touched: the separate stuck-detection scan at
packages/opencode/src/actor/registry.ts:477 still filters on last_turn_time in SQL. It
answers a different question — "is a step taking too long to complete" — which remains
meaningful and is not liveness.

The nullable column

last_activity_time is nullable on purpose: rows predating the migration, and a row read
between register() and its first part write, have genuinely recorded no activity, and NULL
says so instead of inventing a timestamp. register writes null explicitly
(packages/opencode/src/actor/registry.ts:152) and fromRow flattens with ?? undefined
(:44).

The comparison uses ??, never !== undefined. Per AGENTS.md § "Reading a nullable column",
a !== undefined guard on a nullable column is a silent no-op that typechecks and reads
correctly — null !== undefined is true. Probe 2 below is that exact mutation, and it turns
a live child idle.

Step 2 files changed

file:line change
packages/opencode/src/actor/schema.ts:112 DEFAULT_LIVENESS_ABANDON_MS 30 min → 10 min
packages/opencode/src/actor/schema.ts:115 Pick drops lastTurnTime/turnCount, adds lastActivityTime
packages/opencode/src/actor/schema.ts:126 single reference lastActivityTime ?? time.created; turnCount === 0 case deleted
packages/opencode/src/actor/schema.ts:37-42 Actor.lastActivityTime (optional)
packages/opencode/src/actor/actor.sql.ts:34 last_activity_time: integer() (nullable)
packages/opencode/migration/20260729000000_actor_registry_last_activity_time/migration.sql ALTER TABLE … ADD COLUMN
packages/opencode/src/actor/registry.ts:44 fromRow reads it with ?? undefined
packages/opencode/src/actor/registry.ts:152 register writes null
packages/opencode/src/session/projectors.ts:6,140-152 the per-part writer
packages/opencode/src/actor/spawn.ts:940 stall duration matches its predicate
packages/opencode/src/tool/fleet.ts:112 lastActivityMs reads activity

Step 2 tests, and the ones deliberately rewritten

New in test/actor/liveness.test.ts: the mid-step child (recent activity, no completed turn
for 25 m) reads progressing; the converse (many turns, an hour of silence) is not routable;
the bound is pinned to 10 minutes as a value; a null activity column falls back to spawn
time; and an end-to-end case where a real part write advances last_activity_time while
turn_count stays at 0 — progress visible with zero completed steps, which is exactly what
the old signal could not express.

Rewritten, not weakened. Several tests encoded the old step-grained semantics. Each is
marked in-file and makes a strictly more specific claim than the one it replaced:

  • liveness.test.ts "not-yet-started child (turnCount 0) is never stalled" asserted
    progressing for a row silent 10 minutes. That assertion was the fabrication being
    removed. Replaced by the spawn-fallback case, which keeps the real intent (a freshly spawned
    child is not mistaken for wedged) and states it against the mechanism that now carries it.
  • liveness.test.ts "the turnCount-0 leniency still holds inside the abandonment bound"
    asserted progressing at 30 min − 1 ms of silence. Under one uniform bound the honest
    reading is stalled — still routable, so nothing is lost operationally — and the case now
    also pins both sides of the boundary plus one millisecond past it.
  • liveness.test.ts integration "not-yet-started row reads progressing even far past the
    window" — same reason; now asserts the row has no recorded activity, is progressing under
    the real window, and stalled under a 1 ms one.
  • fleet.test.ts and session-tool.test.ts expressed "wedged" by backdating
    last_turn_time. They now backdate activity; the assertions themselves are untouched. The
    session-tool fixture moved from 10 min to 5 min of silence so it still means stalled
    under the tighter bound rather than sliding into idle.
  • stall-watchdog.test.ts expressed "child resumed" as updateTurn, which is no longer
    activity, so the re-arm step silently stopped re-arming. It now also freshens activity —
    which is what a resuming child really does. The notification-count assertions are unchanged.

fleet.test.ts's "a row still claiming running a day after its last turn is not routable" —
the one built from the measured fixture rows — passed unchanged, because a day-old row with
no activity falls back to spawn time and is still past the bound.

Step 2 revert probes, verbatim

Probe 1 — remove the per-part writer in projectors.ts (the mechanism):

416 |           s.updatePart({ id: PartID.ascending(), messageID, sessionID: child.id, type: "text", text: "working" }),
417 |         ),
418 |       )
419 | 
420 |       const after = await rt.runPromise(ActorRegistry.Service.use((reg) => reg.get(child.id, child.id)))
421 |       expect(after!.lastActivityTime).toBeDefined()
      ^
error: expect(received).toBeDefined()

Received: undefined

      at toBeDefined (unknown:1:1)
      at /Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:421:39
(fail) ActorRegistry.liveness (T39 integration) > a part write advances last_activity_time with turn_count still at 0 [550.40ms]

 21 pass  1 fail

Probe 2 — change the nullable read ?? into !== undefined (the AGENTS.md hazard):

285 |       lastOutcome: undefined,
286 |       lastActivityTime: null as unknown as number | undefined,
287 |       time: { created: now - 1_000, updated: now - 1_000 },
288 |     }
289 |     expect(deriveLiveness(raw, now)).toBe("progressing")
                                           ^
error: expect(received).toBe(expected)

Expected: "progressing"
Received: "idle"

      at <anonymous> (/Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:289:38)
(fail) deriveLiveness (T39 derivation rule) > a null activity column falls back to spawn time instead of being treated as present [1.70ms]

A guard whose field is right but whose predicate is wrong: the mutation typechecks, reads
correctly in review, and turns a one-second-old live child into idle.

Probe 3 — widen the bound back to 30 minutes:

237 |   // a silent re-widening is a test failure, not a review question.
238 |   test("the abandonment bound is 10 minutes", () => {
239 |     expect(DEFAULT_LIVENESS_ABANDON_MS).toBe(10 * 60_000)
                                              ^
error: expect(received).toBe(expected)

Expected: 600000
Received: 1800000

      at <anonymous> (/Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:239:41)
(fail) deriveLiveness (T39 derivation rule) > the abandonment bound is 10 minutes [0.34ms]

Probe 4 — restore the step-grained rule (turnCount === 0 ? time.created : lastTurnTime),
i.e. revert step 2's core claim while keeping step 1:

 91 |         },
 92 |         now,
 93 |       ),
 94 |     ).toBe("progressing")
           ^
error: expect(received).toBe(expected)

Expected: "progressing"
Received: "idle"

      at <anonymous> (/Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:94:7)
(fail) deriveLiveness (T39 derivation rule) > child mid-step (no completed turn for 25m) but with recent activity is progressing [0.29ms]

162 |         },
163 |         now,
164 |       ),
165 |     ).toBe("progressing")
            ^
error: expect(received).toBe(expected)

Expected: "progressing"
Received: "stalled"

      at <anonymous> (/Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:165:7)
(fail) deriveLiveness (T39 derivation rule) > exactly at the threshold boundary is still progressing (<= window) [0.19ms]

269 |       time: { created: now - 11 * 60_000, updated: now - 4 * 60_000 },
270 |     }
271 |     // 4m-old activity: stalled under the default 10m bound, abandoned under a 2m one.
272 |     expect(deriveLiveness(row, now)).toBe("stalled")
                                           ^
error: expect(received).toBe(expected)

Expected: "stalled"
Received: "idle"

      at <anonymous> (/Users/mi/projects/mi/mimocode/.worktrees/act-live/packages/opencode/test/actor/liveness.test.ts:272:38)
(fail) deriveLiveness (T39 derivation rule) > custom abandonMs overrides the default bound [0.24ms]

 19 pass  3 fail

Step 2 suite numbers

bun typecheck exit 0.

Same command both sides, same worktree and node_modules, baseline detached at the same
origin/main this branch is rebased onto (b81670870):

bun test test/actor test/tool/fleet.test.ts test/tool/session-tool.test.ts --timeout 120000
pass skip fail tests files wall
pristine origin/main (b81670870) 193 3 0 196 22 155.9 s
this branch (2375280de) 203 3 0 206 22 144.3 s

+10 tests, zero failures on either side — so unlike step 1's run there is no shared
flake set to reason about. (Step 1's table above, with its 5 baseline / 1 branch failures, is
the record of an older run at 60af8f1; the flakes there were load-dependent session tool join timeouts.)

What I did NOT verify

  • No TUI or live-model run. No RUN_ORCHESTRATOR_LIVE suite was executed; a gated suite
    that has not been run is not verification.
  • No test at the roster injection point. Same gap step 1 declared: test/session/llm.test.ts
    and test/session/system.test.ts still have zero hits for deriveLiveness / ROSTER_HEADER /
    listPeerChildren, and I did not add one. The fix is in the shared derivation and is asserted
    through the tool consumers (assembleFleet, session list).
  • The measurements are from one host's databases, not a synthetic benchmark: the timings are
    sqlite3 warm/cold on a 5.4 GB file and will differ elsewhere. The ordering they establish
    (0 extra queries beats 25–38 ms warm on a per-request path) is what the shape decision rests
    on, not the absolute numbers.
  • The gap percentiles cannot isolate "healthy running" silence. They are computed over all
    sub-hour gaps in each slice's activity stream, which includes a child sitting idle between
    turns. A row that is genuinely idle never reaches the window, so those gaps inflate the
    tail rather than the reverse — but I cannot prove from history that no healthy running
    actor is ever silent longer than 10 minutes, which is why the bound keeps ~2× margin over
    p99.9 and errs toward keeping the child routable.
  • I did not migrate an existing database and re-read it. The nullable/pre-migration path is
    covered by a unit case pinning the null shape, not by an on-disk upgrade.
  • A silent tool call is the residual risk. bash calls ctx.metadata() per output chunk
    (packages/opencode/src/tool/bash.ts:679), so a chatty command advances activity
    continuously; a command that emits nothing for minutes (e.g. git worktree add) advances it
    only at start and end. That case is still bounded by the 10 minutes rather than by the step
    window, which is the margin the number is carrying.

Follow-up commit 40f6c807f — the heartbeat's write rate, measured then coalesced

A re-review raised one substantive concern about 2375280de: the activity heartbeat writes far
more often than its consumer's resolution requires. It was right, and the reason it slipped
through is worth stating plainly — the affordability argument in 2375280de's message cited
updatePartDelta (packages/opencode/src/session/session.ts:771), which only calls
bus.publish and never touches the DB. That is true, but it is a different function from the
updatePart on the metadata path
, so the argument was made about the wrong code path and the
actual rate was never measured.

The path, re-verified on this branch

packages/opencode/src/tool/bash.ts:679 ends its per-chunk handler with a bare
return ctx.metadata({ ... }) — no interval check, no coalescing. For the bash tool that
ctx.metadata is the one constructed at packages/opencode/src/session/prompt.ts:1161, which
calls SessionProcessor.updateToolCall; that lands on session.updatePart at
packages/opencode/src/session/processor.ts:332 with no throttle anywhere in between;
updatePart (session.ts:583) runs SyncEvent.run(PartUpdated, …), and the projector
(packages/opencode/src/session/projectors.ts:119) then does the part upsert plus the
registry UPDATE with its correlated subquery over message.

Measured, before

End-to-end on this branch: real BashTool, real Stream.decodeText chunks from a real spawned
shell, real projector against a real SQLite DB, counting statement executions and rows
changed
at the bun:sqlite statement level (a 200,000-line echo loop, three runs):

registry rows rewritten/s part rows written/s ratio
run 1 575 575 1:1
run 2 573 573 1:1
run 3 453 453 1:1

Strictly one registry UPDATE per part upsert per decoded stdout chunk, each one running the
correlated subquery. So the concern was real, not hypothetical.

Two instrumentation artifacts were found and removed before trusting any of these numbers, both
of which had inflated an earlier reading: patching both Database.prototype.prepare and
Database.prototype.query double-wraps the same statement (query is built on prepare), and
building the service layer inside the metadata callback constructs a fresh ActorRegistry layer
per call, whose init re-runs the orphan sweep.

Measured, after

0 registry rows rewritten across the entire burst, bounded at one write per actor per 5 s.

Stated precisely, because it is the honest limit of a WHERE-clause guard: the UPDATE
statement still executes per part write
— the guard suppresses the row mutation, not the
statement. That is the intended and sufficient reduction (no dirtied page, no WAL append, no
index or time_updated churn), and the predicate was deliberately kept in the WHERE rather
than a process-local cache so it stays correct across instances and restarts. It also makes the
column monotonic as a side effect: an out-of-order event carrying an older data.time can no
longer drag it backwards.

The constant

ACTIVITY_COALESCE_MS = 5_000, named next to the other liveness constants in
packages/opencode/src/actor/schema.ts (not inlined in the query), and justified against both
consumers of the column — which are the only things that read it, and are three orders of
magnitude coarser than the write rate:

  • DEFAULT_LIVENESS_STALL_MS (90 s) — the progressing/stalled display. An
    actively-writing actor's recorded activity now lags reality by at most the interval, so
    worst-case apparent age is 5 s against a 90 s threshold: 18× of margin, and the flip is
    unreachable by coalescing alone.
  • DEFAULT_LIVENESS_ABANDON_MS (10 min) — the routable/idle bound: 120× of margin.
  • Above the measured p50 inter-activity gap (994 ms) so it actually coalesces the dense traffic
    it targets, and below p90 (6.7 s) so an ordinarily-paced child still records very nearly every
    activity it has.

IS NULL is the first disjunct because the column is nullable and a fresh row records NULL,
which must still take its first write.

Tests, with revert probes

Two tests were added and neither existing assertion was relaxed — in particular the cases pinning
the 10-minute bound as a value and pinning lastActivityTime ?? time.created are untouched.

  1. Coalescing suppresses redundant writes. A burst of 40 part writes inside the interval
    leaves the column at its first value, while the parts themselves still land and the row still
    reads progressing. Removing the staleness predicate fails it:
    expect(received).toBe(expected) / Expected: 1785347884813 / Received: 1785347884821.
    Inverting the comparison (<>), which silently degrades to no coalescing at all, fails
    it the same way.
  2. Semantics did not move. An actor emitting parts continuously across a span longer than
    ACTIVITY_COALESCE_MS reads progressing at every sample and never lags by more than the
    interval. Keeping only the IS NULL disjunct — so the column freezes after its first write —
    fails it: expect(received).toBeLessThanOrEqual(expected) / Expected: <= 6000 / Received: 6069.
    Raising the constant above the stall window fails its safety precondition:
    expect(received).toBeLessThan(expected) / Expected: < 90000 / Received: 240000.

Scoped run (liveness, stall-watchdog, turn-heartbeat, registry, registry-status,
fleet, session-tool, message-v2): 153 pass, 1 skip, 0 fail, against 141 pass, 1 skip,
0 fail
for the identical command on this branch's base b81670870 — +12 is the 2 new cases
plus the 10 the branch's earlier commit added. bun typecheck exit 0; oxlint 0 errors.

The "saturated concurrency gate" scenario has no mechanism here

The same re-review flagged that a pending child emitting no part for over 10 minutes now reads
idle, hypothesising a saturated concurrency gate holding it un-started. That premise does
not exist in this codebase.
There is no semaphore, permit or concurrency limit anywhere in
packages/opencode/src/actor/ — a search for Semaphore / makeSemaphore / withPermits /
maxConcurrent across that directory returns nothing. The spawn admission path is an immediate
unbounded fork: Actor.spawn (packages/opencode/src/actor/spawn.ts:782) → forkWork
Effect.forkIn(scope) at packages/opencode/src/actor/spawn.ts:657, with nothing gating it. The
single concurrency: occurrence in the whole directory is
packages/opencode/src/actor/spawn.ts:841, and it reads concurrency: "unbounded" — in
Actor.cancel's cascade, not on the spawn path at all.

Combined with this branch's own measurement of first activity after spawn (p50 194 ms, max
1,735 ms, and 0 of 172 children with no activity at all), a child sitting un-started for more
than 10 minutes has no reachable mechanism. Recorded here so the concern is answered rather than
left hanging.

The genuine residual is unchanged and already noted above: a silent tool call such as
git worktree add that writes parts only at start and end.


Follow-up commit 2e5f0886d — the verdict's wording, and its window, re-derived for the activity signal

The two earlier commits switched the input of deriveLiveness to last_activity_time. This one
finishes that change on the two things it left behind: every surface that reports the verdict
still described the step clock, and the threshold was still the number chosen for the step clock.
Both were wrong in the same direction, and together they produced a measured false-positive
channel.

The evidence: a dozen-plus false notifications in one evening

stalled is not display-only. It drives a user-visible notification —
packages/opencode/src/actor/spawn.ts:919 (TUI toast) plus the inbox card rendered by
renderActorNotification — and the card read:

Background sub-session "…" (actor_id: …) appears stalled (no turn advance for 118s).
It is still running but has made no progress.

Two long-running background children spent the evening emitting that at ~90-130 s while
healthy and demonstrably writing parts continuously. They were inside single long steps
(bun ci, a whole test suite, git worktree add), and a tool's streaming output calls
ctx.metadata per chunk, which reaches updatePart and therefore advances
last_activity_time. So the number in the message was silence since the last part write, while
the noun said "turn advance" — two different clocks in one sentence, and the message asserted
has made no progress about children that were visibly progressing.

Over a dozen such notifications were emitted, and the operator began treating every one of them
as noise. That is the actual damage: a false-positive channel trains its reader to ignore it,
so a true stall would have been missed. The fix needed no new mechanism — the column already
exists, is already written, and was already validated on this branch.

The seam, file:line before → after

what before (40f6c807f) after (2e5f0886d)
notification text src/inbox/render.ts:72` (no turn advance for ${…}s)` src/inbox/render.ts:80` (no activity for ${…}s)`
notification body claim src/inbox/render.ts:73has made no progress src/inbox/render.ts:81nothing has landed for it in that time
stalledForMs doc src/inbox/render.ts:39 — "since the child's last turn advanced" src/inbox/render.ts:39-42 — silence; explicitly not time since the last completed step
TUI toast src/actor/spawn.ts:919appears stalled (no duration) src/actor/spawn.ts:927appears stalled (no activity for Ns)
ActorStalled payload src/actor/events.ts:54lastTurnTime src/actor/events.ts:58lastActivityTime (the reference the predicate used)
ActorStalled doc src/actor/events.ts:44-47 — "no turn advance … re-arms after turnCount advances" src/actor/events.ts:43-51 — nothing landed … re-arms once activity lands
watchdog doc src/actor/spawn.ts:867-868 — "now-lastTurnTime > STALL_MS AND turnCount not advancing" src/actor/spawn.ts:867-869 — nothing landed past DEFAULT_LIVENESS_STALL_MS
watchdog re-arm doc src/actor/spawn.ts:878 — "turnCount advanced" src/actor/spawn.ts:879 — "activity landed again"
scan-cadence doc src/actor/spawn.ts:42-45 — "between the per-step turn heartbeat and the … (90s) window" src/actor/spawn.ts:42-45 — "well inside the … (6m) window"
session list heading src/tool/session.ts:984stalled (running/pending, no recent turn) src/tool/session.ts:989stalled (running/pending, no recent activity)
session list doc src/tool/session.ts:958-961 — "(…, lastTurnTime) … updateTurn bumps last_turn_time per step" src/tool/session.ts:958-961 — "(…, lastActivityTime) … the PartUpdated projector bumps last_activity_time per part"
session status doc src/tool/session.ts:1059 — "(status/lastOutcome/lastTurnTime)" src/tool/session.ts:1059-1060 — "(status/lastOutcome/lastActivityTime)"
session status output src/tool/session.ts:1069-1078 — printed only lastTurnTime next to the verdict src/tool/session.ts:1078-1085 — prints lastActivityTime … — liveness derives from this and keeps lastTurnTime … — last COMPLETED step, not the liveness input
fleet heading src/tool/fleet.ts:152stalled (no recent turn) src/tool/fleet.ts:155stalled (no recent activity)
lastActivityMs doc src/tool/fleet.ts:47 — "ms since the last turn advanced" src/tool/fleet.ts:47-50 — ms since anything last landed; "not the step clock"
projector doc src/session/projectors.ts:147 — "deriveLiveness's 90s stall display" src/session/projectors.ts:147 — "deriveLiveness's 6m stall display"

Fourteen sites. The three flagged as at-minimum (schema.ts:58, schema.ts:66, events.ts:44 as
numbered on main) were already correct in schema.ts after 2375280de; events.ts was not, and
eleven more were not. Every one of them described "turn advance" for a predicate that had stopped
reading the turn clock — the same class of stale comment that caused real misreadings in this
codebase.

The threshold: 90 s → 6 min, and why 90 s did not survive the change

Under the old signal 90 s meant "no completed step in 90 s". Under the new one it means
"no part written in 90 s" — a much stronger statement about silence. Re-checked against this
branch's own measurements rather than assumed to carry over:

quantity value 90 s verdict
inter-activity gap p50 994 ms
p90 6,740 ms
p99 38,048 ms 90 s is above p99
p99.9 296,811 ms (≈297 s) 90 s is below p99.9

90 s lands strictly between p99 and p99.9, so between 0.1 % and 1 % of inter-activity gaps
produced by perfectly healthy children exceed it
. Across the 43,120 measured gaps that is up to
~430 natural trips. So switching the signal did not leave 90 s harmless — it converted it into a
new class of false positive, which is exactly what was observed at ~90-130 s.

The number is bounded from below by two measurements:

  1. The deepest measured natural silence, p99.9 = 296,811 ms. Any threshold at or under that
    fires on healthy children by construction.
  2. Plus ACTIVITY_COALESCE_MS = 5_000. Coalescing means an actor's apparent age lags reality
    by up to one interval, so apparent age = real gap + up to 5 s. The threshold must clear
    296,811 + 5,000 = 301,811 ms, or the coalescing lag alone can flip a tail-but-healthy row.
    This is what disqualifies the otherwise tidy 300_000 (which would have equalled
    STUCK_THRESHOLD_MS and read very cleanly): 300,000 < 301,811. It is asserted as a test.

And from above by DEFAULT_LIVENESS_ABANDON_MS = 600_000, which must remain the larger of the two
or stalled becomes unreachable.

DEFAULT_LIVENESS_STALL_MS = 6 * 60_000 (src/actor/schema.ts:110):

  • 1.21× p99.9;
  • clears p99.9 + coalesce by 58,189 ms ≈ 11.6 coalesce intervals of headroom, so the lag cannot
    come near flipping a verdict;
  • 72× ACTIVITY_COALESCE_MS;
  • 0.6× the abandonment bound, leaving a 240 s stalled band ≈ 5 scans at
    WATCHDOG_SCAN_INTERVAL_MS = 45_000;
  • comfortably above the residual noted at the end of this PR (a silent tool call such as
    git worktree add that writes parts only at start and end).

Cost, accepted deliberately: a genuine stall now surfaces within ~6 min instead of ~90 s. The
10-minute abandonment bound still catches a row whose owner is actually gone. A signal that is
believed late beats one that is not believed at all — which is precisely what the old number
produced. Pinned as a value, the same treatment DEFAULT_LIVENESS_ABANDON_MS already gets, so
re-narrowing it fails a test rather than passing review.

stalled remains a distinct verdict with its own threshold — it is not merged into idle.

registry.ts's stuck scan is intentionally unchanged

STUCK_THRESHOLD_MS = 5 * 60 * 1000 and its SQL filter on last_turn_time
(src/actor/registry.ts) are deliberately left on the step signal and are not touched by this
commit
— verified: registry.ts does not appear in 2e5f0886d's file list. That scan answers a
different question, "is a step taking too long", for which the last-completed-step clock is the
correct input. Nothing here argues it should change.

The two required tests, with revert probes verbatim

1. A child inside a long step with recent activity is NOT stalled
test/actor/liveness.test.ts, at the ages actually observed (90,001 / 100,000 / 130,000 ms and
p99.9 = 296,811 ms), with time.created 25 minutes old so no completed step exists to rescue it.
Reverting only src/actor/schema.ts to 40f6c807f (90 s window) and keeping the tests:

281 |       ).toBe("progressing")
              ^
error: expect(received).toBe(expected)

Expected: "progressing"
Received: "stalled"

      at <anonymous> (…/test/actor/liveness.test.ts:281:9)
(fail) deriveLiveness (T39 derivation rule) > a child inside a long step whose activity is 130s old is NOT stalled [0.86ms]

The same probe also fails the value pin and the measurement invariant:

306 |   test("the stall window is 6 minutes", () => {
307 |     expect(DEFAULT_LIVENESS_STALL_MS).toBe(6 * 60_000)
                                            ^
error: expect(received).toBe(expected)

Expected: 360000
Received: 90000
318 |     expect(DEFAULT_LIVENESS_STALL_MS).toBeGreaterThan(GAP_P99_9_MS)
                                            ^
error: expect(received).toBeGreaterThan(expected)

Expected: > 296811
Received: 90000

25 pass, 3 fail on that file with the window reverted; 28 pass, 0 fail with it restored.

2. A child with genuinely no activity past the threshold IS stalled — same file: exactly at the
window is still progressing (the <= convention), one ms past it flips to stalled, and it stays
stalled — routable, not idle — right up to the abandonment bound, flipping to idle one ms
past it. This is the guard that widening the window cannot quietly disable the verdict.

3. The notification says what it measurestest/inbox/parse-actor-notification.test.ts.
Reverting only src/inbox/render.ts to 40f6c807f:

210 |   test("reports silence, not turn advance", () => {
211 |     const text = stalledText(372_000)
212 |     expect(text).toContain("(no activity for 372s)")
                       ^
error: expect(received).toContain(expected)

Expected to contain: "(no activity for 372s)"
Received: "<actor-notification>\nBackground sub-session \"long step child\" (actor_id: general-7) appears stalled (no turn advance for 372s). It is still running but has made no progress. Consider checking on it, sending it a nudge, or cancelling it.\n</actor-notification>"

      at <anonymous> (…/test/inbox/parse-actor-notification.test.ts:212:18)
(fail) renderActorNotification stalled wording > reports silence, not turn advance [0.19ms]
221 |     expect(text).not.toContain("has made no progress")
                           ^
error: expect(received).not.toContain(expected)

Expected to not contain: "has made no progress"
Received: "…appears stalled (no turn advance for 372s). It is still running but has made no progress.…"

      at <anonymous> (…/test/inbox/parse-actor-notification.test.ts:221:22)
(fail) renderActorNotification stalled wording > does not claim the child made no progress, only that nothing landed [0.20ms]

15 pass, 2 fail on that file with the text reverted.

Before each probe the working diff was dumped to a patch outside the worktree and verified with
git apply --reverse --check; after each probe the tree was restored from that patch and confirmed
byte-identical with git diff | diff -q -.

Fixtures deliberately rewritten (no assertion weakened)

Three fixtures encoded the old window as a bare literal and had silently become progressing
rows asserting stalled. Each is restated against the constants, with an in-file REWRITTEN note:

fixture was now
liveness.test.ts "pending is treated as live and split by the same window" lastActivityTime: now - 5 * 60_000 now - (DEFAULT_LIVENESS_STALL_MS + 1)
liveness.test.ts "custom abandonMs overrides the default bound" now - 4 * 60_000, custom bound 2 * 60_000 silentFor = STALL_MS + 60_000, custom bound silentFor - 1
session-tool.test.ts stalled-peer row (in-line comment said "5 minutes is past the 90s stall window") Date.now() - 5 * 60_000 Date.now() - (DEFAULT_LIVENESS_STALL_MS + 60_000)

Each now pins "past the window" — the property the case was always about — at any window value,
so none of them can silently invert again. The claims are identical or stricter; nothing was
relaxed to go green. The session list heading assertion was updated to the reworded heading, and
the session status test gained three assertions (lastActivityTime:,
liveness derives from this, not the liveness input) while keeping its existing
lastTurnTime: assertion.

Suite numbers, against a pristine baseline of the identical command

Command (from packages/opencode):
bun test test/actor/liveness.test.ts test/actor/stall-watchdog.test.ts test/tool/fleet.test.ts test/tool/session-tool.test.ts test/inbox

tree sha pass skip fail expect() tests files
pristine base of this branch b8167087088ad361740d6e28fb35faae49afdf78 129 1 0 411 130 14
branch before this commit 40f6c807f 141 1 0 503 142 14
branch with this commit 2e5f0886d 148 1 0 529 149 14

The baseline sha was printed and compared: it equals git merge-base 40f6c807f origin/main
(b81670870) — origin/main moved repeatedly today, so this is the branch's own base, not
whatever main happens to be now. +7 cases and +26 assertions over the previous head.

bun typecheck exit 0 (forced, Cached: 0 cached, 12 total — not a cache replay).
oxlint on the ten changed files: 0 errors, 42 warnings — identical count before and after, so
this commit introduces none. prettier --write was not run (this repo is not prettier-clean under
its own config); surrounding style was hand-matched.

Remote content verification

Verified by reading back the pushed blobs, not local files:

$ git rev-parse origin/fix/roster-liveness-dead-child
2e5f0886dd6f9bf16ec9d6a1a96c78e87162f40a

$ git show origin/fix/roster-liveness-dead-child:packages/opencode/src/actor/schema.ts | grep -n 'DEFAULT_LIVENESS_STALL_MS = '
110:export const DEFAULT_LIVENESS_STALL_MS = 6 * 60_000

$ git show origin/fix/roster-liveness-dead-child:packages/opencode/src/inbox/render.ts | grep -n 'no activity for'
80:      event.stalledForMs !== undefined ? ` (no activity for ${Math.floor(event.stalledForMs / 1000)}s)` : ""

A scan of every .ts file in the pushed tree for no turn advance / no recent turn /
last turn advanced / turnCount advanc returns exactly one hit — the deliberate contrast comment
at src/inbox/render.ts:74 that explains the correction. git merge-base --is-ancestor 40f6c807f origin/fix/roster-liveness-dead-child passes, so the three pre-existing commits are untouched and
this is a pure append.

@wqymi wqymi changed the title fix: bound a running actor row's claim to be in progress (dead children shown as progressing/stalled) fix(actor): bound deriveLiveness's unbounded running claim — it routed work into children that can never answer and suppressed re-dispatch Jul 29, 2026
wqymi added 2 commits July 30, 2026 01:00
`deriveLiveness` placed unbounded trust in a `running`/`pending` registry row.
Two consequences, both of which make the orchestrator's child roster lie:

- a child that died BEFORE its first turn returned `progressing` forever, because
  `turnCount === 0` returned early and skipped the staleness window entirely;
- a child that died AFTER some turns kept reading `stalled`, and `stalled` is
  presented as "in progress" — routable, and implying work is already in flight.

ActorRegistry's orphan sweep is the only repair for such a row, and it runs once
at process init and only for rows carrying a different instance_id. Until it runs
(and for any row it cannot reach) the derivation must not assert progress.

Adds DEFAULT_LIVENESS_ABANDON_MS (30m), measured from the last turn for a started
child and from spawn time for one that never started. The turnCount-0 leniency is
kept — a queued or cold-starting first turn is still `progressing` — but bounded
instead of unbounded. Past the bound the row reads `idle`: the honest reading and
the only non-routable bucket.
deriveLiveness read last_turn_time, whose only writer is the per-step
heartbeat ActorRegistry.updateTurn. The finest thing it could see was a
COMPLETED step, so a child blocked mid-step — inside a long tool call, a
slow model call, a retry/backoff — was indistinguishable from a dead one.
To avoid killing those, the abandonment bound had to clear the longest
legitimate step (20+ minutes here), which is why it was 30 minutes.

Read the signal that already exists instead. part.time_updated advances on
every part write, so MAX over an actor's slice is "the last time anything
succeeded" at per-API-call granularity, emitted today with no new schema.
Denormalised onto the registry row as last_activity_time rather than joined
at read time: the roster is rebuilt per request and iterates every peer
child, and a read-time MAX over a measured 172-child / 45k-part roster cost
25-38ms warm and 339-487ms cold, versus zero extra queries for a column
that rides along in the row listPeerChildren already selects. The writer is
the PartUpdated projector, the single writer of part rows.

With activity granularity the turnCount === 0 special case and the
30-minute window both collapse into one uniform bound of 10 minutes:
2x the repo's existing STUCK_THRESHOLD_MS, ~16x the measured p99
inter-activity gap (38.0s) and ~2x p99.9 (296.8s), and ~345x the worst
measured first-activity latency after spawn (1735ms, n=172) — which is what
licensed deleting the not-yet-started leniency, since the "slow first turn
queued behind the concurrency gate" it protected is empirically under two
seconds. turnCount is a counter again and is no longer read as evidence.

Tests pinning the old step-grained semantics were rewritten deliberately,
not relaxed; each rewritten case is marked and makes a strictly more
specific claim than the one it replaced.
@wqymi
wqymi force-pushed the fix/roster-liveness-dead-child branch from f3dd1a3 to 2375280 Compare July 29, 2026 17:05
@wqymi wqymi changed the title fix(actor): bound deriveLiveness's unbounded running claim — it routed work into children that can never answer and suppressed re-dispatch fix(actor): derive liveness from last activity, not last completed step — a child blocked mid-step was indistinguishable from a dead one Jul 29, 2026
wqymi added 2 commits July 30, 2026 02:03
…sumers have

The last_activity_time heartbeat added in the previous commit writes on every
part write, and that path has no throttle. bash.ts's per-chunk handler ends in a
bare ctx.metadata(), which reaches Session.updatePart through
SessionProcessor.updateToolCall (session/processor.ts) with no interval check
anywhere on the way, so a chatty shell command performed one registry UPDATE —
carrying a correlated subquery over `message` — per decoded stdout chunk.

Measured end-to-end on this branch (real bash tool, real stream chunks, real
projector, counting bun:sqlite statement executions and rows changed) with a
200k-line command: 453-575 registry rows rewritten per second, strictly 1:1 with
part upserts, over three runs. The earlier affordability argument cited
updatePartDelta (session.ts:771), which does only bus.publish and never touches
the DB — but that is a different function from the updatePart on the metadata
path, so it was made about the wrong code path and the rate was never measured.

Nothing reads the column at that resolution. Its only consumers are
DEFAULT_LIVENESS_STALL_MS (90s, the progressing/stalled display) and
DEFAULT_LIVENESS_ABANDON_MS (10m, the routable/idle bound), so any write beyond
one per tens of seconds is discarded information. ACTIVITY_COALESCE_MS = 5s is
added next to those two constants and applied as a staleness predicate in the
same WHERE: touch the row only when it records no activity yet, or when what it
records is already older than the interval. 18x of margin under the stall window
and 120x under the abandonment bound, above the measured p50 inter-activity gap
(994ms) so it actually coalesces, below p90 (6.7s) so an ordinarily-paced child
still records nearly every activity it has.

Semantics are unchanged. The predicate lives in the WHERE rather than a
process-local cache so it stays correct across instances and restarts, and it
additionally makes the column monotonic: an out-of-order event carrying an older
data.time can no longer drag it backwards. IS NULL is the first disjunct because
the column is nullable and a fresh row records NULL, which must still take its
first write.

Two tests. One pins that a burst of writes inside the interval leaves the column
at its first value while the parts themselves still land and the row still reads
progressing; removing the predicate fails it (Expected 1785347884813, Received
1785347884821), and inverting the comparison — which degrades to no coalescing —
fails it the same way. One pins that an actor emitting parts continuously across
a span longer than the interval reads progressing at every sample and never lags
by more than the interval; keeping only the IS NULL disjunct, so the column
freezes after its first write, fails it (Expected <= 6000, Received 6069), and
raising the constant above the stall window fails its safety precondition
(Expected < 90000, Received 240000).

After the change the same chatty command rewrites the registry row 0 times
across the whole burst, bounded at one write per actor per 5s. The UPDATE
statement still executes per part write — the guard suppresses the row
mutation, not the statement — which is the intended and sufficient reduction:
no dirtied page, no WAL append, no index or time_updated churn.
…to that

deriveLiveness already classified `stalled` off last_activity_time, but every
surface that reports the verdict still described the step clock, and the window
was still the number picked for the step clock. Both were wrong in the same
direction, and together they produced a measured false-positive channel.

Wording. The one user-visible surface, renderActorNotification, read
`appears stalled (no turn advance for Ns)` while N was silence since the last
part write — the noun and the number described different clocks. Two background
children inside single long steps (`bun ci`, a full test suite, `git worktree
add`) emitted a dozen-plus such notifications at ~90-130s while writing parts
continuously: a tool's streaming output calls ctx.metadata per chunk, which
reaches updatePart and advances last_activity_time. It now reads `no activity
for Ns`, and no longer claims the child "has made no progress" — a long step IS
progress, and we cannot see inside one, only whether anything is coming out of
it. Same correction to the TUI toast (which now also carries the duration), the
`session list` and fleet bucket headings ("no recent activity"), the
ActorStalled payload (lastTurnTime -> lastActivityTime, the reference the
predicate actually used), and six stale comments.

Window. 90_000 meant "no completed step for 90s"; read against activity it means
"no part written for 90s", a far stronger claim of silence. Against this branch's
own 43,120 measured inter-activity gaps (p50 994ms / p90 6.7s / p99 38.0s /
p99.9 296.8s), 90s lands strictly between p99 and p99.9 — between 0.1% and 1% of
gaps from HEALTHY children exceed it. Switching the signal turned the old
threshold into a new false-positive class rather than leaving it harmless.

6 minutes. Bounded below by the deepest measured natural silence (p99.9 =
296,811ms) PLUS ACTIVITY_COALESCE_MS, because recorded activity lags reality by
up to one coalesce interval and the threshold must clear 301,811ms or that lag
alone can flip a tail-but-healthy row. That is what rules out the otherwise tidy
300_000 (== STUCK_THRESHOLD_MS): it is below 296,811 + 5,000. 360_000 is 1.21x
p99.9, clears p99.9+coalesce by 58.2s, is 72x the coalesce interval, and leaves
a 240s `stalled` band (~5 scans at WATCHDOG_SCAN_INTERVAL_MS). Pinned as a
value, like the abandonment bound, so re-narrowing fails a test. Cost accepted
deliberately: a genuine stall surfaces in ~6min instead of ~90s, still
backstopped by the 10m abandonment bound — a signal believed late beats one that
is not believed at all.

registry.ts's stuck scan (STUCK_THRESHOLD_MS, SQL filtering on last_turn_time)
is deliberately untouched: "is a step taking too long" is a different question,
and the step clock is the right input for it.

Two fixtures encoding the old window as bare literals (5 minutes in
liveness.test.ts and session-tool.test.ts, 4 minutes in the custom-abandonMs
case) are restated against the constants with in-file notes — they had silently
become `progressing` rows asserting `stalled`. No assertion was weakened.
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