feat(coding-agent): event-driven supervisor agent roster serving list from one ledger - #1897
Conversation
79444c5 to
194c5b7
Compare
05960ed to
8dd6901
Compare
194c5b7 to
bc71397
Compare
8dd6901 to
d1ab404
Compare
c58eb05 to
c86f81a
Compare
e59b37a to
f5e960d
Compare
sethkarten
left a comment
There was a problem hiding this comment.
The supervisor-owned, event-driven roster is directionally the right design. Before approving, I need the state-authority model clarified.
ResidentWorker now contains both summaries and roster-related state such as rosterEpoch, rosterApplyChain, rosterRepairPull, lastFrameAt, and rosterStale, while AgentRoster is described as the supervisor-owned projection.
Please document:
- Which structure is authoritative for listing, routing, recovery, eviction, and reconnect reconciliation.
- Why
worker.summariesmust remain afterlistmoves toAgentRoster. - What prevents
worker.summariesandAgentRosterfrom diverging. - Why each roster ordering/repair field is needed and which race it prevents.
If worker.summaries is only a legacy cache, please remove it or restrict it to a transient pull result. If it is required, add a concise invariant comment near ResidentWorker so future lifecycle changes do not accidentally treat both representations as authoritative.
I am not asking for a broad redesign. I want the lifecycle ownership and consistency model to be explicit before this becomes the basis for listing and routing.
…ration Passivating an owned worker's rows strips the workerId and turns private rows into public inactive rows (path/cwd/name/message metadata) served to every client through offline list paths and roster reads. Client-owned workers are ephemeral, so their rows die with the registration; the public disk scan still lists whatever files actually persist.
…he recovery SIGKILL
…ion evictability rule
…rite-through pulls Adapts the empty-session last-detach eviction (from the idle-eviction fix round on main) to the roster world with one decision source: - isEmptyDetachEvictionCandidate reads the worker's non-queued roster rows instead of the worker.summaries pull cache. - The hook's two pulls stay as responsiveness gates and now write through: syncRosterFromWorkerSummaries (formerly the gap fill) lets a worker's own rows take the pull's fields, so the post-drain re-read deterministically sees a schedule registered by a mutation admitted mid-refresh. The pull-epoch guard keeps every write-through at least as fresh as the row it replaces, and rows claimed by another worker are never stolen. - The detach-eviction tests seed the supervisor roster like the other adapted suites (matchWorkers is roster-backed). Semantics of the empty-detach eviction are unchanged: empty + unnamed + not busy + no registrations + no attached clients, last detach only, client-owned workers excluded, fence coordination intact.
cronStore.onHeartbeatChange only fires on the heartbeat catalog signature, and cron_add/cron_cancel emit no session event, so hasRegisteredCronJob on the roster row went stale: the idle sweep could evict a worker whose only reason to stay resident was a fresh cron job, or keep a cancelled one pinned forever. The handlers flush explicitly, like set_model does for events that have no session-event carrier.
…a claimed child The absentee reseed wrote a synthetic ledger seed (no lastActivityAt, messageCount 0, artifact-dir cwd) over a previously hydrated claimed row. Every worker snapshot goes through this for passive registry children, and Date.parse(undefined) = NaN made canEvictWorker permanently false while the degraded row persisted; plain list served the degraded fields too. The reseed now rewrites the previous entry's summary (claim and data both survive); only rows with no prior entry get the synthetic workerless seed.
- flushRoster's queuedChildren loop var is an agentId (parent-qualified), not a bare childId; name it so. - set/cycle_thinking_level drop their explicit roster flushes: an actual change emits thinking_level_changed, which is a trigger already (the set_model flushes stay - model changes emit no session event). - Non-worker daemons no longer accumulate removedAgentIds that no flush ever drains. - The changelog stops presenting recovering/last-heard-from as user-visible in this PR; the surfaces that display them ship in the follow-up.
…nd fence applies on socket close - A pending removal now records the sessionId it removes. A row composed again under the same agentId with a different sessionId (or a re-admitted queued run) is a new incarnation and cancels the stale removal instead of being suppressed from every flush including the reconnect snapshot; the removed incarnation itself stays suppressed mid-teardown so a deleted child cannot ghost back as a passivated row. - isWorkerRosterApplyCurrent also requires a live (or authenticating) connection: an apply left in flight by a closed socket can no longer rewrite rows and drop the recovering label handleWorkerClose just set. Reconnection resumes applies through the pending client.
Comments: 153 -> 33 added src comment lines. Kept only notes resolving real ambiguity (pull-epoch guard, close fence, reseed/NaN rationale, incarnation suppression, privacy rules, backpressure delivery assumption, pid-recycle and SIGKILL-wait justifications, wire-schema notes); deleted all narration. Tests: one behavior test per contract. Merged into their parent behavior test: bind-window compose-wins, trigger republish, queued rows ledger-internal, queued-ghost flip, offline rename + failed-disk delete, client-owned inactive list row, unchained-delta currency, set_model no-carrier flush, reseed data quality, qualified removal ids. Deleted pins whose behavior another test or the process-suite E2E already proves: undelivered-change escalation, real- socket backpressured snapshot, recovering-on-close (asserted in the close- fence test), frame-source trust, seeded selector resolution, root-pointer epoch persist, miss-path refresh routing, hydrate race, sessions-dir topology scoping, delivery-semantics mock twin, descriptor-path delete-routing variant.
… review round - The offline delete's roster cleanup deletes only the row object it observed: a write during the tombstone/unlink awaits replaces the row, and deleting by agentId alone would kill the replacement. - Subagent roster ids fall back to the live parent id when the parent has no session path (--no-session parents never write ledger edges), so children of two such parents cannot collide on the per-parent 32-bit child id. - An archived top-level close (killed/completed/replaced; not shutdown/update) publishes a roster removal instead of leaving a passivated "live" ghost: the worker's list no longer carries the session and the disk scan serves the archived file honestly. Subagent rows keep passivating, mirroring the registry's completed children. - Roster applies are fenced by their own source connection: an apply parked on the spawn-ledger read by a dead connection can no longer resume during a reconnect's pre-auth window and clear the recovering labels, while the authenticating connection's own post-auth snapshot still applies immediately.
- One shared roster-seeding fixture (test/fixtures/roster-seed.ts) replaces the five per-suite copies. - Deleted mechanism pins with accepted residual risk: flush change-dedup and trigger-set micro-pins (the lifecycle test still pins the closed-session flip), the single-flight repair pull, the mid-pull epoch skip, the crafted late-update guard phase, and the second pre-roster identity scenario. - Another comment pass: dropped notes that restate the guard beside them.
…reconciled seeds, one file-ownership source - pendingRlmSpawnAppends is keyed by parent + childId at every site: child ids are only unique per parent, and a cross-parent collision made one admission await the wrong ledger append while the other proceeded without awaiting its own durable spawn record. - The roster's ledger seeding and snapshot reseeds read liveEdges(), the ledger's own stat-reconciled view (the rule family() already owned): rows whose transcript was removed out-of-band never serve in list --all. Tombstone-first covers in-band deletes; this covers external removal. - findWorkerBySessionFile no longer consults the stale pull cache: the roster claim and the durable descriptor paths are the ownership sources, so a removed row cannot route a create back to a worker that would answer with its root session. - classifyWorkerRosterEntry is module-private (no consumer outside the module). - The changelog notes the client-owned exception to inactive-row retention.
…ssion swaps new_session/switch_session/fork swap the runtime under the same state: the activeSessionId survives while the sessionId (and so the top-level agentId) changes. The old agentId vanished from composition without a close, so the passivation-retention loop kept serving it as a stale claimed row that plain list never carried before the roster. The flush loop now treats a vanished row whose activeSessionId still composes under a different agentId as a removal — one owner for every swap origin, no per-command bookkeeping — and the pending-removal cancel rule also revives resident top-level rows (switch-back, resume-after-archive) while the resident-subagent teardown race stays suppressed.
1639737 to
0acfeb2
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0acfeb2. Configure here.
…coped reseeds, filter-all tombstones - family() builds its child suppression from the same single replay + stat snapshot that emits child rows: a sessions-dir child whose parent transcript vanished degrades to a root row instead of disappearing, and a concurrent cross-process append can no longer make the two views disagree. - Snapshot reseeds are scoped to the snapshotting worker's own family: the reseed exists to restore that worker's absentee-swept registry children, and resurrecting other families' unclaimed rows leaked a client-owned worker's just-dropped children back into list --all as public rows (the ownership record is already gone by then, so this scoping IS the privacy rule). - Transcript deletes tombstone every edge matching the path: appendSpawn's per-process uniqueness check leaves a cross-process TOCTOU window, and a raced duplicate left live would resurrect a later recreation as a subagent. - The changelog states the staleness behavior honestly: rows are as fresh as the worker's last delta, silence is annotated rather than hidden.
Addressed, and we're close to a release and for timezone reasons I'll get a different reviewer and merge asap.
… from one ledger (PrimeIntellect-ai#1897) * feat(coding-agent): event-driven supervisor agent roster; serve list from the ledger Workers now push roster deltas to the supervisor on session events (roster_delta/roster_heartbeat worker frames, compute-on-event and send-only-if-changed, plus a 15s unref'd heartbeat tick). The supervisor keeps one roster ledger seeded at startup from the session catalog and the RLM spawn ledger (tombstones excluded), classifies status exactly once at write via classifyAgentStatus, and serves list, selector matching, family catalogs, and peer rosters from it. Deletions this enables: - handleList per-worker fan-out with its 5s timeout and silent stale summaries; list now does zero worker round-trips. - Event-triggered blanket refreshWorkerSummaries (kept only as a per-worker shim for legacy workers that do not advertise the roster capability in their worker_auth response). - mergeSessionLists; 'list all' is served from the already-merged ledger. - streamingMessage off the list wire (recovery/adoption refresh still seeds the stream reconstructor). Visibility and liveness: - Admitted child runs appear as queued roster rows before their session exists and merge into the session row when it binds. - Close, passivation, and eviction flip rows to inactive; rows are removed only for discarded drafts and spawn-ledger delete records. - A dead worker's rows are marked recovering natively on socket close and failed when recovery gives up; one 15s unref'd watchdog stamps lastHeardFromAt on rows of workers silent for more than 45s. busyClientOwnedSessionCount and daemon-launch busy checks are pinned by tests; roster frames live in the worker protocol, not the client schema, so no client protocol change ships in this part. Part 2 of 3 for the event-driven daemon-owned agent roster. ENG-5794 * fix(coding-agent): review fixes for the supervisor agent roster - list keeps its resident-only contract: non-all list emits only sessions with an activeSessionId; queued child runs and passivated rows stay ledger-internal, and list all carries the non-resident rows (owned rows keep their workerState/workerPid). sessionDir on list all now filters rows by sessions dir (including its sibling session-artifacts tree) instead of being ignored. - Offline saved-session renames and deletes, and worker-side saved-session deletes, now write the roster ledger. - Supervisor (re)authentication makes the worker send a replacing roster snapshot; rows absent from the snapshot passivate when a transcript exists and are removed otherwise. Pending state commits only after a frame reaches an authenticated supervisor, and the supervisor registers its frame listener before authenticating so the snapshot cannot race. - Remaining worker.summaries read paths (wake fallback, create reuse and readiness) moved to the ledger; create-forward and rename refreshes are gated to legacy workers, with the returned summary written to the ledger. - The roster wire summary keeps modelFallbackMessage for the active-open path. - Queued-run supersession has one mechanism (session rows overwrite queued rows at flush); the run-lifecycle cleanup in observeRosterChildUpdate is pinned by a bind-then-close test, and the saved-delete test proves the supervisor removes the ledger row end-to-end. - Worker roster reporter state is created lazily so prototype-based fixtures exercising worker_auth cannot crash the flush path. ENG-5794 * fix(coding-agent): roster review fixes round two - Non-all list restores the pre-roster population exactly: worker-owned rows (materialized and passivated) stay listed; sessionless queued-child rows are served by no list form; seeded/offline rows remain all-only. - The reauth snapshot is the worker's complete roster: composition always runs (delivery-gated separately), passivated rows persist in a lastComposed map independent of delivery, and pending removedAgentIds ride the snapshot frame so deletions survive a disconnect; the supervisor applies removals after replacement. - Queued-run supersession has one mechanism at the queued-entry lifecycle: observeRosterChildUpdate deletes the queued row when the child's session is bound and its write guard rejects late queued updates for bound children; roster composition order carries no semantics (verified by insertion-order reversal). - list-all sessionDir scoping matches artifact-dir children through their owning root's sessions dir instead of the shared sibling artifacts tree, so sibling session dirs no longer leak each other's subagents. - Worker roster reporter state is a plain field initializer again; the prototype-based worker_auth fixture constructs the state it needs. ENG-5794 * fix(coding-agent): roster bot-review fixes - A child run that terminates before binding is a roster removal, never a passivated phantom row. - list all rescans the disk per call (supervisor-local catalog subprocess, no worker round-trips) and merges with the ledger, which wins for rows it knows; sessionDir defaults to the configured sessions dir, the seed scan passes it too, and name validation reads the same per-call catalog path. Seeding now exists for selectors, name checks, and liveness only. - Saved-session deletes publish removals only when the file was actually deleted, resolve the roster agent id through the ledger entry or spawn edge (childId for subagents), append the spawn-ledger tombstone so deleted subagents never reseed, and offline deletes of worker-owned passivated files forward to the owning worker instead of being rejected as active. - Roster frames respect backpressure: a non-drained socket gets no writes, delivery requires an accepted write, undelivered state stays uncommitted, and a drain re-flushes it. - Roster agent ids qualify child ids by parent path: child ids are 32-bit and uniqueness-checked only per parent (agent-session mkdir loop), so bare ids collide across parents at scale. - findWorker's miss path refreshes all workers once, closing the just-bound-but-unflushed routing window without reviving the hot-path fan-out; a summaries refresh no longer overwrites roster deltas that landed while its list request was in flight. - Seeded artifact-dir rows hydrate their real cwd lazily from the transcript header on first list-all use, keeping startup free of per-child file reads. ENG-5794 * fix(coding-agent): roster bot-review fixes round two - Both remaining removal producers (rlm subagent deletion and discarded bound-child drafts) publish parent-qualified agent ids through one shared resolution (rosterAgentIdForRlmChild), matching the qualified row keys. - Delivery authority is the live supervisor claim: hasAuthenticated- SupervisorClient and broadcastRosterFrame require supervisorClaims membership, so a revoked socket can never satisfy delivery. - Generation-acked tombstone retention closes the kernel-write-vs-consumed gap: every roster frame carries a monotonic generation, delivered removals are retained as tombstones, the supervisor acks its last consumed generation in worker_auth, the reauth snapshot replays newer tombstones, and the worker prunes acked ones (recreated agents drop their stale tombstones at composition). - A refresh response staler than a mid-flight delta is discarded entirely (one bounded retry) instead of partially applied, and the eviction snapshot reads the roster so a busy delta always outranks a stale list. - Saved-child deletes append the spawn-ledger tombstone FIRST and abort on append failure; a tombstoned-but-undeleted file is the accepted orphan of a failed delete and keeps its roster row for retry. ENG-5794 * fix(coding-agent): child deletes never proceed past an unreadable spawn ledger Child-ness of a saved-session delete target now comes from worker-held state (the file-indexed composed roster entry or the transcript's parent metadata), never from a ledger read that can fail. For a child target an edges() rejection or a failed tombstone append aborts before file deletion with the error surfaced and no removal published; top-level targets never touch the spawn ledger. ENG-5794 * fix(coding-agent): classify unreadable delete targets through the spawn ledger Saved-session delete targets discriminate three ways: a readable no-parent transcript (or composed top-level row) is positively top-level and skips the spawn ledger; a positively-child target keeps the unguarded tombstone-first path; an UNKNOWN target (no composed row, unreadable or corrupt header — readSessionInfo's null is normalized so it cannot pass as readable) classifies via the ledger, where an edge means child, no edge means top-level, and a failed read aborts before deletion with no removal published. ENG-5794 * fix(coding-agent): roster bot-review fixes round three - Offline deletes honor descriptor-based ownership: a worker owning the file without a claimed roster row forwards when reachable and rejects with a retryable error when its socket is down, so a transcript is never deleted underneath a live owner. - The supervisor offline delete uses the worker path's three-way discrimination (positively top-level, positively child, unknown-via- ledger with abort on an unreadable read), so catalog-seeded children without rlmChildId and unreadable targets still tombstone first. - The list session-dir parent walk uses a visited-set cycle guard instead of a hop cap, staying correct at any depth. - Seeded artifact rows derive their session id from the transcript filename so persisted-session-id selectors resolve before any worker delta; edge.childId remains the child identifier. - Worker frames carry their source connection and are dropped when a superseded connection's buffer flushes after a reconnect. - list all treats the disk as authoritative for non-resident rows, propagates scan failures instead of shrinking the list, and preserves the newest-first catalog order with worker rows replacing their scanned files in place. ENG-5794 * fix(coding-agent): roster bot-review fixes round four - Worker frames accept exactly the current client and the in-flight replacement (worker.pendingClient, set before authentication and cleared in a finally on success or rollback), so a replacing connection's immediate snapshot is never discarded while the old client is still installed. - Offline deletes reclaim a dead failed registration through the existing reclaim machinery before proceeding; live or recovering owners keep the retryable rejection. - Depth-33 parent chains and self-cycles are pinned for session-dir scoping, and seeded artifact rows are pinned to resolve by their persisted transcript id when the filename differs from the child id. ENG-5794 * refactor(coding-agent): drop the lossless roster channel; disk is durable truth - Deltas become best-effort freshness hints: any undelivered, refused, or backpressured write just marks a pending snapshot, and one full replacing snapshot flows on (re)connect or drain. Generation counters, delivered-commit bookkeeping, tombstone retention with ack and prune, and the worker_auth rosterGeneration ack all go away. - The supervisor applies a snapshot atomically: it replaces the worker's rows, deletes absent rows outright, then reseeds subagent families from the spawn ledger with tombstoned edges filtered out. Tombstone-first delete classification stays on both delete paths. - Undelivered removal ids stay pending and ride the first delivered frame, so removals of unattributed rows survive backpressure. - The startup catalog seed goes away; list all, name checks, and worker matching already read disk per call, so only the spawn-ledger seed remains. - The roster test suite consolidates into lifecycle, delivery, delete- path, and regression groups: one queued-child lifecycle scenario, one snapshot escalation pin, one snapshot-replace-and-reseed pin, and an ownership-routing table replace the per-round accretions; the depth-33 walk pin drops with the machinery it guarded. ENG-5794 * perf(coding-agent): cache roster row serializations across flushes Change detection reuses the previous flush's JSON strings, so a churny flush stringifies each current row once instead of twice. ENG-5794 * refactor(coding-agent): restart pre-roster workers on adoption; drop the legacy shim - connectWorker rejects a worker_auth response without the roster capability, so adoption of a pre-roster worker routes through the existing recoverWorker machinery and respawns it from the current binary; sessions reload idle and resume on the next prompt. - The rosterCapable flag, the legacy event-refresh branch, both conditional refresh call sites, and the refresh-vs-delta race guard go away; deltas own the roster and pulled summaries only feed recovery stream seeding, eviction checks, and descriptor pointers. - syncWorkerSummariesIntoRoster shrinks to a gap filler: launch and recovery pulls fill missing rows and claim workerless seeded rows (registry children no delta composes) without ever overwriting delta-fed rows, so no ordering guard is needed. - Tests seed rosters via writeRosterEntry, eviction fixtures seed the delta-fed rows they previously got from refresh syncs, and a new pin covers the adoption restart routing. ENG-5794 * fix(coding-agent): roster rework review fixes, supervisor and worker halves - Worker frames adopt real socket semantics: a write queued under backpressure IS delivered, so pending state clears on it; only an absent, destroyed, or unauthenticated claim socket is a loss gap, and one replacing snapshot closes it. Drains never resend queued frames. - Gap fills are epoch-guarded: every applied roster frame bumps a supervisor-local per-worker counter, a pull that straddled a frame re-pulls once, and a still-moving epoch skips the fill entirely so a stale list can never resurrect a just-removed row. - Snapshot applies pre-read the spawn ledger and queue later frames behind them per worker, so replacement, absentee deletion, and the tombstone-filtered reseed land atomically with no transient removal. - The startup catalog seed returns: a push-only view needs saved top-level rows in the ledger itself. Rows stay slim and list-all keeps its per-call disk rescan. - Pre-roster adoption performs a real bare restart: the durable descriptor is the whole respawn context, the old process is killed only under its observed identity, and launchWorker respawns from the current binary. Pinned end-to-end against a real supervisor with a capability-less fake worker, no recovery mocks. - Model, thinking-level, and rename changes reach subscribers: the thinking_level_changed trigger joins the roster event set and the four model/thinking handlers schedule a flush. ENG-5794 * test(coding-agent): await owned process exits before teardown rmSync The shared afterEach now awaits every tracked child and worker pid before deleting temp directories, and rmSync retries transient failures, so a dying worker's log writer cannot race the cleanup into ENOTEMPTY. Hardened in the shared helper because every test in this file spawns supervisors and workers through the same teardown. ENG-5794 * fix(coding-agent): serialize roster pulls with frame applies and harden owner resolution - bump the roster epoch at frame receipt and route pull gap-fills through the one per-worker apply chain (chainWorkerRosterApply) - resolve delete owners through findWorkerBySessionFile, which now also consults pulled worker summaries for unflushed child rows - restartPreRosterWorker launches a replacement only against a confirmed-stopped predecessor; unverifiable live processes keep the worker failed - canonicalize session paths in findActiveSessionByFile so the active guard matches the tombstone/removal side across symlinks - flush the roster projection after execute_bash_and_wait * test(coding-agent): pin snapshot/pull serialization, pre-roster restart guard, symlink delete guard - a pull fill queued behind an in-flight snapshot re-claims reseeded rows - an unverifiable live pre-roster worker stays failed with no replacement - delete_saved_session through a symlink hits the active-session guard * fix(coding-agent): abort queued roster applies for unregistered workers; launch only on a confirmed-stopped predecessor - chained frame applies and pull fills re-check the worker registration before running and after the snapshot's ledger pre-read, so a stop can never be overwritten by a resumed apply - a failed partial apply schedules one gap-fill pull as repair - restartPreRosterWorker launches only when the final identity verdict is gone or replaced; a current-to-unknown flip keeps the worker failed * test(coding-agent): let the stop land mid pre-read in the snapshot-abort pin * fix(coding-agent): single-flight roster repair pull with a logged failure - a per-worker marker caps repair pulls at one in flight; repeated apply failures reuse it and a failing repair cannot respawn itself - a failed repair logs one warning naming the worker * test(coding-agent): reduce the roster suite to distinct behavior pins - drop the delete round-trip, supervisor unknown-target classification, modelFallbackMessage projection, discarded-draft removal ids, and the duplicated ledger-read-abort scenario; each surviving pin is named in the review ledger - one makeOfflineSupervisor helper replaces four hand-rolled real supervisor constructions; the queued-child test now also pins delta removals * test(coding-agent): drop an unused import after the projection pin removal * test(coding-agent): fix formatting after the removal-id pin cut * test(coding-agent): final reviewer-directed roster suite cuts - collision qualification folds into the queued-child lifecycle pin - one population matrix covers seeding, resident worker rows, and eviction; the standalone passivated-children test is absorbed - the supervisor staleness sweep pin moves to the push-layer test only - the two pre-roster restart scenarios become one named table - the real-socket test drops its fixed sleep; the top-level delete pin asserts the exact removed session id * test(coding-agent): biome format for the population matrix * test(coding-agent): pin resident and seeded rows side by side in one live list-all * chore(coding-agent): comment sweep — one-line present-tense rationale, drop dead fixture fields - condense the moved two-line busy-projection comment and the test section banners to one line each - present-tense fixes in two test comments - delete the dead rosterCapable/lastFrameAt/rosterStale fixture fields * fix(coding-agent): republish retry/tool transitions and guard pulled root pointers - auto_retry_* and tool_execution_* events join the roster flush triggers: they flip isSessionActive/activity and isRunningTools; the flush already coalesces per tick and sends only changed rows - the pulled root descriptor persists through the per-worker apply chain under the epoch guard, so a stale list can never clobber pointers a frame updated mid-pull * refactor(coding-agent): rename AgentRosterLedger to AgentRoster * refactor(coding-agent): one owner each for busy/status adapters, registration flags, delete tombstone policy, and the roster heartbeat contract - isSessionSummaryBusy and classifySessionRosterStatus move into agent-roster.ts (re-exported from daemon-session-list.ts for existing importers); classifyWorkerRosterEntry now delegates instead of re-inlining the busy predicate. - The user-delete classification + tombstone-first policy lives once in rlm-ledger.ts (tombstoneSavedSessionDelete); the worker and supervisor delete_saved_session routes both call it. - passivatedWorkerRosterEntry never freezes hasRegisteredHeartbeat/hasRegisteredCronJob: the worker flush recomputes them from the cron store via the extracted scheduledJobRegistrations index (the one registration truth); callers without a cron store strip them. - ROSTER_HEARTBEAT_INTERVAL_MS moves next to the roster capability in daemon-worker-protocol.ts; the supervisor staleness threshold derives from it (three missed heartbeats) instead of restating 45s. * fix(coding-agent): supervisor roster correctness batch - Offline delete_saved_session asserts client access to the owning worker before forwarding or reclaiming: a foreign client's delete of a client-owned worker's passivated session is an unknown target again. - Adopted pre-roster workers with an owner are parked through recoverWorker (their launch env lives only with the owning client) instead of a bare descriptor respawn that would drop it. - A worker's queued-child rows are removed, not passivated, when its registration goes away: a terminal unbound run owns no transcript, and the fileless ghost row nothing could list or delete is gone. - Snapshot reseeds keep a passive registry child's previous worker claim, and gap fills also replace synthetic ledger seeds, so passive children stop flapping out of the non-all list and stale frozen rows stop feeding eviction. - hydrateSeededEntry re-checks the row after its header read; a frame that rebinds the agentId mid-read is never clobbered with the stale seed. - matchWorkers and findSummaryInWorker skip queued-child rows: there is no session to route to, and a queued name must not create false ambiguity. - familyCatalogEntries is fail-closed again: a failed catalog scan propagates instead of silently shrinking name-uniqueness checks. - The idle-eviction pull is documented as a responsiveness gate; the decision data comes from the delta-fed roster. - handleList list-all merge drops the O(n^2) includes() and overlaps seeded-row header reads. * test(coding-agent): pin the roster correctness batch - foreign client delete of a client-owned worker's passivated session rejects as unknown - worker unregistration removes queued rows instead of passivating unlistable ghosts - hydrateSeededEntry never clobbers a row rebound during its header read - passive registry children keep their worker claim across snapshots that omit them and stay in the non-all list; the queued gap fill also replaces the synthetic ledger seed with the pulled summary - the worker reporter fixture carries the real lastComposedJson field * fix(coding-agent): let composed session rows beat lingering queued markers addRuntime registers a child session before the bind-reporting rlm_child_update arrives; a roster flush in that window replaced the resident row with its sessionless queued stub. Session rows now win at compose time and clear the stale queued marker. * fix(coding-agent): keep unserved worker files listed as inactive rows in list all A client-owned worker's row sits in activeByFile even when the client is not served it; the list-all merge then dropped both the live row and the catalog row, hiding the session entirely. The on-disk scan is public (no list surface filters it by ownership), so the file lists as a plain inactive row again, exactly like before the roster ledger. * fix(coding-agent): guard roster applies against dead registrations and unreadable ledgers - Unchained (fast-path) deltas now re-check registration currency exactly like chained applies: a late frame from an unregistered or replaced worker registration cannot resurrect its rows with a stale claim. - A snapshot whose spawn-ledger pre-read fails skips the absentee sweep and reseed (it cannot tell registry children from stale rows without edges), keeps applying the snapshot's own entries, and schedules the single-flight repair pull instead of silently deleting passive children. * fix(coding-agent): drop client-owned workers' roster rows on unregistration Passivating an owned worker's rows strips the workerId and turns private rows into public inactive rows (path/cwd/name/message metadata) served to every client through offline list paths and roster reads. Client-owned workers are ephemeral, so their rows die with the registration; the public disk scan still lists whatever files actually persist. * fix(coding-agent): re-verify pid identity at the last moment before the recovery SIGKILL * fix(coding-agent): import the moved busy predicate for the empty-session evictability rule * fix(coding-agent): serve empty-detach eviction from the roster with write-through pulls Adapts the empty-session last-detach eviction (from the idle-eviction fix round on main) to the roster world with one decision source: - isEmptyDetachEvictionCandidate reads the worker's non-queued roster rows instead of the worker.summaries pull cache. - The hook's two pulls stay as responsiveness gates and now write through: syncRosterFromWorkerSummaries (formerly the gap fill) lets a worker's own rows take the pull's fields, so the post-drain re-read deterministically sees a schedule registered by a mutation admitted mid-refresh. The pull-epoch guard keeps every write-through at least as fresh as the row it replaces, and rows claimed by another worker are never stolen. - The detach-eviction tests seed the supervisor roster like the other adapted suites (matchWorkers is roster-backed). Semantics of the empty-detach eviction are unchanged: empty + unnamed + not busy + no registrations + no attached clients, last detach only, client-owned workers excluded, fence coordination intact. * fix(coding-agent): flush the roster on plain cron job add and cancel cronStore.onHeartbeatChange only fires on the heartbeat catalog signature, and cron_add/cron_cancel emit no session event, so hasRegisteredCronJob on the roster row went stale: the idle sweep could evict a worker whose only reason to stay resident was a fresh cron job, or keep a cancelled one pinned forever. The handlers flush explicitly, like set_model does for events that have no session-event carrier. * fix(coding-agent): keep the hydrated summary when a snapshot reseeds a claimed child The absentee reseed wrote a synthetic ledger seed (no lastActivityAt, messageCount 0, artifact-dir cwd) over a previously hydrated claimed row. Every worker snapshot goes through this for passive registry children, and Date.parse(undefined) = NaN made canEvictWorker permanently false while the degraded row persisted; plain list served the degraded fields too. The reseed now rewrites the previous entry's summary (claim and data both survive); only rows with no prior entry get the synthetic workerless seed. * chore(coding-agent): roster review nits - flushRoster's queuedChildren loop var is an agentId (parent-qualified), not a bare childId; name it so. - set/cycle_thinking_level drop their explicit roster flushes: an actual change emits thinking_level_changed, which is a trigger already (the set_model flushes stay - model changes emit no session event). - Non-worker daemons no longer accumulate removedAgentIds that no flush ever drains. - The changelog stops presenting recovering/last-heard-from as user-visible in this PR; the surfaces that display them ship in the follow-up. * fix(coding-agent): scope pending roster removals to one incarnation and fence applies on socket close - A pending removal now records the sessionId it removes. A row composed again under the same agentId with a different sessionId (or a re-admitted queued run) is a new incarnation and cancels the stale removal instead of being suppressed from every flush including the reconnect snapshot; the removed incarnation itself stays suppressed mid-teardown so a deleted child cannot ghost back as a passivated row. - isWorkerRosterApplyCurrent also requires a live (or authenticating) connection: an apply left in flight by a closed socket can no longer rewrite rows and drop the recovering label handleWorkerClose just set. Reconnection resumes applies through the pending client. * chore(coding-agent): slim roster comments and consolidate roster tests Comments: 153 -> 33 added src comment lines. Kept only notes resolving real ambiguity (pull-epoch guard, close fence, reseed/NaN rationale, incarnation suppression, privacy rules, backpressure delivery assumption, pid-recycle and SIGKILL-wait justifications, wire-schema notes); deleted all narration. Tests: one behavior test per contract. Merged into their parent behavior test: bind-window compose-wins, trigger republish, queued rows ledger-internal, queued-ghost flip, offline rename + failed-disk delete, client-owned inactive list row, unchained-delta currency, set_model no-carrier flush, reseed data quality, qualified removal ids. Deleted pins whose behavior another test or the process-suite E2E already proves: undelivered-change escalation, real- socket backpressured snapshot, recovering-on-close (asserted in the close- fence test), frame-source trust, seeded selector resolution, root-pointer epoch persist, miss-path refresh routing, hydrate race, sessions-dir topology scoping, delivery-semantics mock twin, descriptor-path delete-routing variant. * fix(coding-agent): roster identity and staleness fixes from the sixth review round - The offline delete's roster cleanup deletes only the row object it observed: a write during the tombstone/unlink awaits replaces the row, and deleting by agentId alone would kill the replacement. - Subagent roster ids fall back to the live parent id when the parent has no session path (--no-session parents never write ledger edges), so children of two such parents cannot collide on the per-parent 32-bit child id. - An archived top-level close (killed/completed/replaced; not shutdown/update) publishes a roster removal instead of leaving a passivated "live" ghost: the worker's list no longer carries the session and the disk scan serves the archived file honestly. Subagent rows keep passivating, mirroring the registry's completed children. - Roster applies are fenced by their own source connection: an apply parked on the spawn-ledger read by a dead connection can no longer resume during a reconnect's pre-auth window and clear the recovering labels, while the authenticating connection's own post-auth snapshot still applies immediately. * chore(coding-agent): second slim pass on roster tests and comments - One shared roster-seeding fixture (test/fixtures/roster-seed.ts) replaces the five per-suite copies. - Deleted mechanism pins with accepted residual risk: flush change-dedup and trigger-set micro-pins (the lifecycle test still pins the closed-session flip), the single-flight repair pull, the mid-pull epoch skip, the crafted late-update guard phase, and the second pre-roster identity scenario. - Another comment pass: dropped notes that restate the guard beside them. * fix(coding-agent): seventh review round — spawn-append scoping, stat-reconciled seeds, one file-ownership source - pendingRlmSpawnAppends is keyed by parent + childId at every site: child ids are only unique per parent, and a cross-parent collision made one admission await the wrong ledger append while the other proceeded without awaiting its own durable spawn record. - The roster's ledger seeding and snapshot reseeds read liveEdges(), the ledger's own stat-reconciled view (the rule family() already owned): rows whose transcript was removed out-of-band never serve in list --all. Tombstone-first covers in-band deletes; this covers external removal. - findWorkerBySessionFile no longer consults the stale pull cache: the roster claim and the durable descriptor paths are the ownership sources, so a removed row cannot route a create back to a worker that would answer with its root session. - classifyWorkerRosterEntry is module-private (no consumer outside the module). - The changelog notes the client-owned exception to inactive-row retention. * fix(coding-agent): remove, not passivate, rows renamed by in-place session swaps new_session/switch_session/fork swap the runtime under the same state: the activeSessionId survives while the sessionId (and so the top-level agentId) changes. The old agentId vanished from composition without a close, so the passivation-retention loop kept serving it as a stale claimed row that plain list never carried before the roster. The flush loop now treats a vanished row whose activeSessionId still composes under a different agentId as a removal — one owner for every swap origin, no per-command bookkeeping — and the pending-removal cancel rule also revives resident top-level rows (switch-back, resume-after-archive) while the resident-subagent teardown race stays suppressed. * fix(coding-agent): ninth review round — one family snapshot, family-scoped reseeds, filter-all tombstones - family() builds its child suppression from the same single replay + stat snapshot that emits child rows: a sessions-dir child whose parent transcript vanished degrades to a root row instead of disappearing, and a concurrent cross-process append can no longer make the two views disagree. - Snapshot reseeds are scoped to the snapshotting worker's own family: the reseed exists to restore that worker's absentee-swept registry children, and resurrecting other families' unclaimed rows leaked a client-owned worker's just-dropped children back into list --all as public rows (the ownership record is already gone by then, so this scoping IS the privacy rule). - Transcript deletes tombstone every edge matching the path: appendSpawn's per-process uniqueness check leaves a cross-process TOCTOU window, and a raced duplicate left live would resurrect a later recreation as a subagent. - The changelog states the staleness behavior honestly: rows are as fresh as the worker's last delta, silence is annotated rather than hidden.
Takes upstream's event-driven supervisor roster ledger + push (PrimeIntellect-ai#1897, PrimeIntellect-ai#1900), direct TUI<->worker transport (ENG-5817), daemon startup/recovery hardening (PrimeIntellect-ai#1929, PrimeIntellect-ai#1909), single-dump kernel snapshots (PrimeIntellect-ai#1945), empty-draft eviction (PrimeIntellect-ai#1946), rlm_child_update suppression (PrimeIntellect-ai#1944), bash-skill preview (PrimeIntellect-ai#1911). Fork laws re-expressed on the roster architecture: - stable-target follow-up honesty kept (capability proof via worker hello, target_unavailable never not_found when ownership unproven) - schema revision 26 (union of fork rev-24 stable-target + upstream rev-24/25 roster+transport); digest minted by the repo's own algorithm - summary freshness reuse + single-flight + staleness + root-omission rejection restored on upstream's refresh pull - adoption/recovery never fails a live worker on a slow or root-omitting catalog: get_state root seed + stale mark + bounded background rehydration - repl.py keeps fork prune-on-aggregate-overflow - delete handlers keep fork persistence reporting; eviction fence test keeps the stronger two-worker contention variant Known test debt (deferred to post-Wave cleanup per owner): roster-era fixture migrations in daemon-supervisor-monitor (2), plus un-triaged failures in package-command-paths, agent-session-recursion, daemon-runtime-stress, 4600-supervisor-singleton, 4603-worker-recovery, 4606-update-restart- coordinator, agents-view-roster. Production laws preserved; failures are fixture-era artifacts or mechanism assertions to rewrite.
- Direct session transport between TUI and worker (ENG-5817, PrimeIntellect-ai#1926) - Event-driven supervisor agent roster with push subscriptions (PrimeIntellect-ai#1897, PrimeIntellect-ai#1900, PrimeIntellect-ai#1895) - Hardened daemon startup, recovery ownership, and worker launch diagnostics (PrimeIntellect-ai#1929, PrimeIntellect-ai#1918) - Python REPL runtime single-dump snapshots and bash preview tool (PrimeIntellect-ai#1945, PrimeIntellect-ai#1911) - Non-blocking RLM subagent deletion and snapshot update suppression (PrimeIntellect-ai#1954, PrimeIntellect-ai#1944) - Saved catalog loading on Agents View open (PrimeIntellect-ai#1960) - Advanced Anthropic prompt caching marker across tool results (PrimeIntellect-ai#1927) - TUI process replacement on update and empty draft eviction (PrimeIntellect-ai#1631, PrimeIntellect-ai#1946, PrimeIntellect-ai#1920)

Part 2 of 3 for ENG-5794 (https://linear.app/primeintellect/issue/ENG-5794). Stacked on #1895; base is
feat/agent-roster-classifier, so this diff is PR2-only. Part 3 (subscription push + TUI consumption) follows.Summary
roster_delta/roster_heartbeatprivate worker frames): send-only-if-changed deltas from every session/child lifecycle point, queued RLM children included before any session exists, plus a complete replacing snapshot on every (re)auth that carries pending removals — deletions survive supervisor disconnectsAgentRosterLedger: status classified once at write via the shared classifier (feat(coding-agent): shared agent-status classifier, honest worker visibility, subagents bar rename #1895), seeded at startup from the catalog and the RLM spawn ledger (artifact-dir subagents included, tombstones excluded), then kept current purely by eventslistis served synchronously from the ledger with zero worker round-trips; the per-worker fan-out with its 5s timeout, the silent stale-summaries path, andmergeSessionListsare deleted; the public list population is byte-compatible with pre-PR2 behavior (pinned by the restored process test)recoveringnatively via socket close; recovery exhaustion marksfailed; one 15s unref'd watchdog stampslastHeardFromAtafter 45s of silence (honest staleness instead of silently stale data)worker.summariesremains only for adoption/recovery/eviction; wake fallback, create readiness, and reuse lookups read the ledger;sessionDirfiltering matches artifact-dir subagents by owning-root topology (no sibling-dir cross-contamination)Validation
npm run checkgreenNote
High Risk
Core daemon supervision and session listing now depend on a new worker–supervisor roster protocol and ledger consistency; legacy workers are rejected until restarted, and delete/list/ownership paths were rewired around tombstones and roster state.
Overview
Moves daemon
listoff per-worker pulls onto a supervisor-ownedAgentRosterledger: workers advertiseagent_rosteron auth and pushroster_delta(diff or full snapshot on reconnect) plusroster_heartbeatover private worker frames; the supervisor applies frames with epoch/chaining, seeds offline rows from the session catalog and RLM spawn ledger, and serveslistsynchronously from that store.Workers compose and flush roster rows from session lifecycle (including queued subagents before a session exists, passivated/evicted rows, and explicit removals on archive/delete/swap). Worker disconnect marks rows recovering; prolonged silence sets
lastHeardFromAtvia a watchdog. Client-owned workers drop roster rows when unregistered; sharedtombstoneSavedSessionDeleteruns before saved-session deletes.pendingRlmSpawnAppendskeys are scoped byparentActiveSessionId#childId. Legacy workers without the capability are rejected (PreRosterWorkerError) and non–client-owned ones may be restarted on adoption.Reviewed by Cursor Bugbot for commit 385f566. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add event-driven
AgentRosterto daemon supervisor and worker protocolAgentRosterin agent-roster.ts as the single source of truth for session listing, root selection, and ownership checks in the supervisor, replacing direct reads ofworker.summariesroster_delta(snapshot or diff) androster_heartbeatframes to authenticated supervisors; the protocol is extended in daemon-worker-protocol.ts withDAEMON_WORKER_ROSTER_CAPABILITYandDaemonWorkerRosterOutboundagent_rostercapability, and throwsPreRosterWorkerErrorfor legacy workers; non-client-owned legacy workers are auto-restarted during recoverytombstoneSavedSessionDeletebefore file removal; roster entries for deleted/closed sessions are removed in the next deltaready, displaying the worker state as the status labelagent_rostercapability will be rejected and restarted;pendingRlmSpawnAppendskeys change fromchildIdto${parentActiveSessionId}#${childId}in daemon-mode.ts — any out-of-tree consumers of that map will breakChanges since #1897 opened
DaemonSupervisorroster reseed logic to filter spawn-ledger edges by session family root [385f566]RlmSpawnLedger.familyUnlockedmethod to emit orphaned children as root rows and modified delete-session tombstoning to cover all duplicate edges [385f566]Macroscope summarized 1639737.