Repository navigation
feat(board): offline workers, reconnect reconciliation, and worker removal - #190
Conversation
…ver (wip) Contracts, migration 049, decider, projections, mover and supervisor groundwork for T3O-66: placementOrphaned cards, the worker-removed stall, force-move phases, removePreview, and the supervisor's per-worker reach (held signals, scoped reconcile, timeout credit, deferred probes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…move phases Abandoning a removed worker's worktree now ends the card's hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al preview, header indicator Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, removal and force-move Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brentkelly
left a comment
There was a problem hiding this comment.
High-level summary
This PR finishes the distributed board's failure handling. Signals from an unreconciled worker are held, and a scoped reconcileWorker runs on reconnect before those signals are replayed and before schedule(). Timeouts and usage-limit probes skip paused workers, and timeout reference times are shifted by the time a worker was offline. Worker removal and its preview are decided atomically in the decider, as fallback, orphaned or reattached outcomes. Force-move gains abandon and restart-from-base phases, and the web gets the notice ranking, the force-move dialog and the header attention pill. The structure is sound: removal outcomes are decided with the removal event, reconcile reuses the boot path, and local-only boards are left alone.
This round has 1 critical, 3 improvement and 1 nitpick finding. The critical one is the PR's headline force-move path. A step stranded on an offline or removed worker, once requeued on its new worker, reads its old thread's state before checking which worker that thread is on. Against the real RemoteThreadGateway, that read answers unknown and liveThread fails. The failure leaks the slot that was just acquired and aborts the whole schedule() pass. The test for this path passes only because the fake gateway answers for offline workers. The improvements are:
- a reconnect that falls between two syncs is missed, because the sync samples current status rather than the transition
- "no remote branch" is inferred from any failure to fetch the remote
- restart-from-base moves cards to Build whatever their stage
None of these are cosmetic.
| yield* dispatch({ | ||
| type: "board.card.requeue-step", | ||
| commandId: yield* commandId("requeue-step"), | ||
| cardId: card.id, | ||
| preserveBudget: true, | ||
| createdAt: yield* nowIso, | ||
| }); |
There was a problem hiding this comment.
Caution
Critical (1/1): a requeued stranded step reads its old, unreachable thread first, which fails, leaks a slot and aborts schedule()
After a force-move (or a re-pin of an orphaned card), the step stalled with worker-removed is requeued, and admitBuildCandidate / admitPlanStep call resumeExistingStepThread. That function calls threadGone(state.threadId) before the T3O-65 worker-mismatch check below it. The thread still lives on the old worker, which by definition is offline or removed. ThreadGatewayRouter routes the read to RemoteThreadGateway.threadState, which returns UNKNOWN_THREAD_STATE when there is no link, and liveThread turns that into a ThreadReadError.
In admitBuildCandidate, slots.acquire has already succeeded at that point, and nothing catches the failure:
- the slot on the new worker leaks
- the error escapes the
forloop inschedule(), so every candidate ordered after this card is not admitted on that pass - the same thing happens on every later pass until the old worker comes back, leaking one slot each time
The new test a force-move strands the step on the offline worker, then runs it where it lands passes only because threadGateway.testkit's threadState answers for any thread regardless of whether its worker is online.
Suggested fix: Do the link/worker comparison first, and return "no-thread" without touching the gateway when the thread's worker differs from where the card runs now. In the harness, make the fake gateway answer UNKNOWN_THREAD_STATE for threads linked to an offline or removed worker, so this test exercises the real behaviour.
There was a problem hiding this comment.
Fixed in f8491c3. resumeExistingStepThread now checks whether the link's worker matches where the card runs before it reads the thread, so a thread on an offline or removed worker is never read through the gateway. The force-move test a force-move strands the step on the offline worker, then runs it where it lands (workerOffline.test.ts) now wraps the gateway so the W1 thread reads UNKNOWN_THREAD_STATE, the way RemoteThreadGateway answers for an unreachable worker. It fails before the fix (no spawn: liveThread throws after the slot is acquired) and passes after it.
There was a problem hiding this comment.
Adjudication (round 1): fix-upheld. resumeExistingStepThread now returns no-thread on a worker mismatch before threadGone reads the old thread. The force-move test's gateway now answers UNKNOWN_THREAD_STATE for the W1 thread. I put the old check order back and the test failed, so it really catches this bug. With the fix in place it passes.
| for (const workerId of reconciledWorkers) { | ||
| if (!online.has(workerId)) reconciledWorkers.delete(workerId); | ||
| } | ||
| for (const workerId of registered) { | ||
| if (!online.has(workerId) && !unreachableSince.has(workerId)) { | ||
| unreachableSince.set(workerId, nowMs); | ||
| } | ||
| } | ||
| // A removed worker is forgotten; its cards are the removal's business. | ||
| for (const map of [unreachableSince, offlineIntervals, heldSignals]) { | ||
| for (const workerId of map.keys()) if (!registered.has(workerId)) map.delete(workerId); | ||
| } | ||
| for (const workerId of online) { | ||
| if (reconciledWorkers.has(workerId)) continue; |
There was a problem hiding this comment.
Warning
Improvement (1/3): a worker that drops and returns between two syncs is never treated as having been offline
syncWorkerReach samples the current workersNow status and ignores the transitions. placement.changes is a Stream<void> tick, and the reactor queue is serial. If a worker goes offline and comes back before the queued workers item runs, a busy queue or a short network blip is enough. In that case the item sees online, the worker is still in reconciledWorkers, and nothing happens:
- no reconcile runs before the resumed signal stream is processed, which the brief requires on every reconnect
unreachableSinceis never set, so the offline time is not credited, andsweepTimeoutscan time out and recover a step whose thread was simply out of reach
gateThreadSignal has the same gap, because it trusts reconciledWorkers membership.
Suggested fix: Key reconciliation on a per-connection generation or epoch from WorkerConnections (or record each transition as it is observed) rather than sampling status. A worker whose connection generation changed since its last reconcile is then unreconciled even if it reads online now, and its offline interval comes from the connection's own timestamps.
There was a problem hiding this comment.
Fixed in 0087641. BoardPlacement.changes now emits the summary each change produced, not a void tick. The reactor's subscriber records every worker that isn't online in droppedWorkers as each summary arrives, along with when it first saw it. syncWorkerReach consumes that set first: it un-reconciles the worker and starts its time out of reach from when the drop was seen, so a worker that is already back online still gets the scoped reconcile and the offline credit. gateThreadSignal and workerPausedNow treat a worker with a pending drop as unreconciled, so its signals are held right away. New test: a drop and reconnect both landing before the supervisor looks is still reconciled (workerOffline.test.ts). It fails before the fix (0 recoveries) and passes after it. I chose a timestamped drop set over a per-connection epoch because the summary carries no epoch, and adding one would mean changing the WorkerConnections and contracts types for the same result.
There was a problem hiding this comment.
Adjudication (round 1): fix-upheld. changes now carries each summary. The subscriber records every non-online worker in droppedWorkers as the summary arrives. syncWorkerReach un-reconciles those workers and credits their offline time, and workerReconciled gates signals while a drop is pending. The new drop-and-reconnect test passes.
Residual, non-blocking: in production, BoardWorkers.states is Stream.merge(...).pipe(Stream.mapEffect(() => state)). It reads the current state when each tick is pulled, not the state that caused the tick. The test's fake placement emits the real value, so it does not cover this. The remaining window is now the scheduling latency of an idle consumer fiber, not the serial queue's processing time. A real reconnect needs a network handshake, so this is negligible. Mapping each connections.states value into the summary would close it completely.
| const target = yield* workspaces.forWorker(toWorkerId, card.projectId); | ||
| const tip = yield* target | ||
| .remoteBranchTip({ branch: worktree.branch }) | ||
| .pipe(Effect.orElseSucceed(() => null)); | ||
| if (tip === null) { | ||
| return { | ||
| outcome: "no-remote-branch", | ||
| workerId: from, | ||
| branch: worktree.branch, | ||
| baseRefName: worktree.baseRefName, | ||
| } as const; | ||
| } | ||
| return { outcome: "ready", fromWorkerId: from, branch: worktree.branch, tip } as const; |
There was a problem hiding this comment.
Warning
Improvement (2/3): any failure to read the remote is reported as "no remote branch", which offers Restart from base
LocalBoardWorkspace.remoteBranchTip returns null both when the remote lacks the branch and when it cannot be reached ("null when the remote does not have it (or cannot be reached)"). On top of that, .pipe(Effect.orElseSucceed(() => null)) folds every other failure into null: a target worker whose RPC dropped, a missing checkout, or an auth failure on fetch.
On the force path, null becomes outcome: "no-remote-branch", and the dialog then asks "Restart {card} from {base}?". Confirming forgets the card's branch (worktree: null) and cuts Build again from base. So a transient fetch failure on the target can lead a human to throw away a card's pushed work, with copy telling them it was never pushed. The non-force path treats a null tip as a hard refusal, which is safe. The force path turns it into an offer to discard.
Suggested fix: Make the workspace distinguish "branch absent" from "couldn't ask". For example, return a tagged result, or fail on fetch and transport errors and keep null for a confirmed-missing ref. Offer no-remote-branch only for the former, and refuse with "Couldn't reach the remote from {target}" otherwise.
There was a problem hiding this comment.
Fixed in 1cd4a01. LocalBoardWorkspace.remoteBranchTip now asks git ls-remote --heads first. An empty answer means the branch is absent and returns null. A failed ls-remote or a failed fetch fails with BoardWorkerGitError ('The remote … couldn't be reached.'). No remote configured at all still returns null. In BoardCardMove, checkForced refuses when the read fails, and the non-force path does the same when there is no source to move from, instead of folding the failure into no-remote-branch. Tests: a remote that can't be asked refuses the force-move rather than offer a restart (BoardCardMove.test.ts) fails before the fix and passes after it. BoardWorkspace.contract.test.ts now points origin at a missing path and asserts remoteTipUnreachable === 'BoardWorkerGitError' on both the local and the remote (RPC) workspace.
There was a problem hiding this comment.
Adjudication (round 1): fix-upheld. remoteBranchTip runs ls-remote --heads first. A non-zero exit or a thrown error fails with BoardWorkerGitError. Empty output returns null (branch absent). A failed fetch after a positive ls-remote also fails instead of returning null. In both checkForced and the move, the mover turns a failed read into refused(...couldn't reach the remote...), so only a confirmed-absent branch gets no-remote-branch. The new mover test and the contract test pass.
| const restartFromBase = Effect.fn("BoardCardMover.restartFromBase")(function* ( | ||
| cardId: BoardCardId, | ||
| stamp: BoardMoveStamp, | ||
| ) { | ||
| const board = yield* readBoard; | ||
| const card = board.cards.find((candidate) => candidate.id === cardId)!; | ||
| const build = boardStageWithRole(board, "build"); | ||
| if (build !== null && card.stage !== build.stageId) { | ||
| yield* dispatchStamped( | ||
| { | ||
| type: "board.card.move", | ||
| commandId: yield* commandId("move-restart-build"), | ||
| cardId, | ||
| toStage: build.stageId, | ||
| override: true, | ||
| createdAt: yield* nowIso, | ||
| }, | ||
| stamp, | ||
| ); | ||
| } | ||
| return yield* record(cardId, { phase: "restarted" }, stamp); |
There was a problem hiding this comment.
Warning
Improvement (3/3): restart-from-base moves the card to Build whatever stage it is in, and ignores a refused move
The docblock says "A card past Build goes back to it first", but the guard is card.stage !== build.stageId, so any other stage triggers it. Two cases go wrong:
- A card in a stage before Build that still holds a worktree, for example one sent back to Planning, is jumped forward into Build with
override: true, skipping the planning the human just asked for. - A Done card with a kept worktree (T3O-52) that a human force-moves off an offline worker is dragged back out of Done.
Also, dispatchStamped's boolean is discarded. If board.card.move is refused, restarted is still recorded. The card is then left in its later stage with worktree: null and its branch forgotten, and nothing tells the human.
Suggested fix: Compare boardStageIndex(board, card.stage) with Build's index, and move only when the card is past Build and not Done. Refuse the restart, or leave the stage alone, for earlier stages and for Done. If the stage move is refused, return refused(...) before recording restarted. Add tests for a pre-Build card and a Done card.
There was a problem hiding this comment.
Fixed in 1cd4a01. checkForced refuses no-remote-branch for a Done card, so restart can't be reached for it. restartFromBase moves the card back to Build only when isBoardStageAtOrAfterBuild holds. A card not yet at Build keeps its stage, its branch is forgotten, and Build cuts a fresh branch from base when the card gets there. I chose that over refusing, because a refusal would leave a pre-Build card stuck on a dead worker with no way out. The board.card.move result is now checked: if the move is refused, the mover records a failed move phase and returns refused, rather than recording restarted. Tests that fail before the fix and pass after it: with no remote branch, a Done card is not restarted and with no remote branch, a card not yet at Build restarts in its own stage. The refused-stage-move branch has no direct test, because the mover's fixture decider has no way to refuse an override move.
There was a problem hiding this comment.
Adjudication (round 1): fix-upheld. restartFromBase only moves a card back to Build when isBoardStageAtOrAfterBuild. Done cards are refused earlier, in checkForced. A refused stage move now records failed and returns false, and the caller (BoardCardMove.ts:549) turns that into refused. The Done-card and pre-Build tests pass.
| * And one stalled because its worker was removed (T3O-66), once the card | ||
| * holds no more: it was moved or re-pinned, or the worker was added back. | ||
| * Requeued, it resumes its thread where that thread still is, or starts | ||
| * afresh on the card's new worker. |
There was a problem hiding this comment.
Note
Nitpick (1/1): requeueResolvedPlacementStalls's docblock sits on provisionPlacedBuildStep
This docblock (which predates this PR, and which the PR extends with the T3O-66 paragraph) is followed directly by provisionPlacedBuildStep's own docblock. Editors and hover documentation attach neither block to requeueResolvedPlacementStalls, which sits about 20 lines below.
Suggested fix: Move this comment block down so it sits directly above const requeueResolvedPlacementStalls.
There was a problem hiding this comment.
Fixed in f8491c3: the docblock now sits directly above requeueResolvedPlacementStalls.
There was a problem hiding this comment.
Adjudication (round 1): fix-upheld. The docblock now sits directly above requeueResolvedPlacementStalls.
…e reading it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l reconciled Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nch, and restarts only cards that can go back to Build Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brentkelly
left a comment
There was a problem hiding this comment.
High-level summary — round 2 (re-review at 1cd4a01)
This PR completes the distributed board's failure handling. A worker the board can't reach pauses its steps: they aren't timed or recovered and keep their slots. When it comes back, a reconcile scoped to that worker runs before its held signals are released, and its offline time is credited to the step timeout. Removal decides each card's fallback or hold atomically in the decider, and stops and stalls running steps. Force-move continues from the remote branch, or restarts from base after a second confirmation. The web side adds the Worker removed / Worker offline notices, the force-move and removal dialogs, and the header indicator.
All five round-1 fixes hold up on re-reading. The worker-mismatch check now runs before the thread read. Drops are recorded as each summary arrives. ls-remote tells an absent branch from an unreachable remote. Restart only sends cards at or after Build back to it, and refuses Done cards. Reading the code fresh turned up two new improvement findings and nothing critical. First, a reconcile pass that fails partway forgets which threads it already decided, so held signals from before it can be replayed against steps it already recovered or parked. Second, a step parked on a removed worker (awaiting input, paused, or stalled) is neither stalled nor requeued by removal or force-move, so it stays bound to an unreachable thread. That second gap can also leave a provider usage limit uncleared. Both are edge paths with no test coverage. The core offline, reconnect and removal flows look sound.
Status of round-1 findings
| # | prior finding | status |
|---|---|---|
| r1-c1 | 🔴 A requeued stranded step reads its old, unreachable thread first, leaking a slot and aborting schedule() |
✅ closed |
| r1-i1 | 🟡 A worker that drops and returns between two syncs is never treated as having been offline | ✅ closed. Residual tick-pull latency is negligible. |
| r1-i2 | 🟡 Any failure to read the remote is reported as no-remote-branch, which offers Restart from base | ✅ closed |
| r1-i3 | 🟡 Restart-from-base moves the card to Build from any stage and ignores a refused move | ✅ closed |
| r1-n1 | 🟢 requeueResolvedPlacementStalls docblock sits on provisionPlacedBuildStep |
✅ closed |
| const decidedThreads = yield* reconcileWorker(workerId); | ||
| if (decidedThreads === null) continue; |
There was a problem hiding this comment.
Warning
Improvement (1/2): a reconcile that only partly finishes forgets which threads it already decided, so the held signals it keeps are replayed against those steps later
reconcileWorker returns null if any one step's liveThread read fails. That happens when the worker drops again partway through the pass. When it does, syncWorkerReach hits continue here. It doesn't clear heldSignals for that worker, and it throws away the decidedThreads set built so far. But the steps that pass already reconciled stay reconciled: settleStep, recoverStep and parkStepForInput have dispatched, and a recovered step may already be running a nudge turn.
On the next attempt (the next timeout-sweep tick, or the next reconnect), the steps that pass 1 recovered or parked usually come back as resume-watch or reschedule. Their thread ids are no longer in decidedThreads. So the signals held from before pass 1 are replayed through processThreadSignal. For example, a held turn-ended reaches a step that has already been recovered and is running its nudge. A held turn-requested reaches a step that pass 1 parked, and handleTurnStartRequested resumes a stalled step. The builder's notes say this filter exists to stop exactly this, a turn-end held from before the reconcile causing a second recovery. That protection only holds when the first pass finishes. There is no test for a partial pass followed by a retry.
Suggested fix: Keep the decided threads per worker across attempts, for example decidedSinceDrop: Map<BoardWorkerId, Set<string>>. Merge each pass's set into it, and clear it only when the worker is marked reconciled and its held signals have been released. Alternatively, drop every held signal for a thread that any pass on that worker decided. Add a test where one step's read fails on the first pass, the retry succeeds, and a held turn-end for the first step does not trigger a second recovery.
There was a problem hiding this comment.
Fixed in 2c97278. Decided threads now live in a per-worker decidedThreads map that reconcileWorker adds to on every attempt, including partial ones. The map is cleared only when the held signals are released, or when the worker is removed or forgotten. Test: a reconcile that fails partway still drops the held signals it already decided in workerOffline.test.ts. One step's read fails on the first pass while the other step is recovered onto its backoff rung. On the retry, the held board turn-start and turn-end are not replayed. Before the fix the step was woken off its rung and recovered a second time (expected 2 to equal 1).
There was a problem hiding this comment.
Adjudication (round 2): fix-upheld. Checked at a0bae48. In 2c97278, decidedThreads is now a per-worker map that lasts across reconcile attempts. A pass that fails partway still adds the threads it advanced, recovered or parked. The map is cleared only after a whole pass releases the held signals, or when the worker is removed or unregistered. The new test "a reconcile that fails partway still drops the held signals it already decided" fails when 2c97278 is reverted and passes now (15/15 in workerOffline.test.ts).
| for (const state of boardNonTerminalStepStates(board)) { | ||
| if (state.status !== "running" && state.status !== "completing") continue; | ||
| if (slotKeyFor(board, state).workerId !== workerId) continue; |
There was a problem hiding this comment.
Warning
Improvement (2/2): removal and force-move only stall running and completing steps, so a parked step stays bound to a thread on the removed worker that nobody can reach
stallStepsOnRemovedWorker skips every step that isn't running or completing. stallStrandedStep and boardCardWorkerRemovalOutcome apply the same filter. A step parked on the removed worker is skipped too: awaiting-input (a question or a stop), paused, or stalled for usage-limit or needs-a-human. Its thread link still names that worker. What happens next depends on whether the card has a worktree.
- No worktree (for example, a planning step waiting on a question): the card gets
fallbackand moves to the default worker with no notice. The step still shows Input needed. The human's answer would have to go to a thread on a worker that is gone, andgateThreadSignaldrops that worker's signals anyway. Nothing ever requeues it, becauserequeueResolvedPlacementStallsonly picks upworker-removedandprovider-not-on-workerstalls. - Has a worktree: the card is orphaned and the human force-moves it.
stallStrandedStepskips the parked step, and when the move lands the requeue skips it too. The card arrives on its new worker still parked on the old, unreachable thread. - Usage-limit stall:
slotKeyForresolves to the removed worker, soworkerPausedNowis true for it forever. The provider-limit probe filters the step out, and if it was the instance's only parked card, the limit never clears.
None of the removal, fallback or force-move tests cover a parked step.
Suggested fix: On removal, and on a force-move or pin off an unreachable worker, also stall the card's parked step with worker-removed when its thread link names that worker. requeueResolvedPlacementStalls already restarts such a step fresh on the card's new worker through the link-worker check in resumeExistingStepThread. Add a decider or supervisor test with an awaiting-input step on the removed worker, for both the fallback case and the orphaned-then-force-moved case.
There was a problem hiding this comment.
Fixed in a0bae48. A new stepBoundToThread predicate covers running, completing, awaiting-input, and stalled steps other than worker-removed that have a thread. stallStepsOnRemovedWorker and stallStrandedStep both use it, so parked steps on the removed or abandoned worker are stalled worker-removed too. Removal then calls requeueResolvedPlacementStalls(), so a fallback card restarts on its new worker straight away. A held card still waits for its move. A usage-limit park on the removed worker no longer counts toward its instance's cooldown, so the limit can clear. A human paused step is deliberately left alone. The decider refuses recover-step on paused, and the human's resume goes through resumeExistingStepThread, whose worker check starts afresh on the card's current worker. boardCardWorkerRemovalOutcome is unchanged: a parked step doesn't hold the card, and it now restarts on the default instead. Tests: removing the worker restarts a fallback card's parked step on the default (awaiting-input) and a force-move off an offline worker takes a parked step with it (usage-limit). Both fail before the change and pass after. The existing removal test now sets placementOrphaned as the decider does.
There was a problem hiding this comment.
Adjudication (round 2): fix-upheld. Checked at a0bae48. stepBoundToThread now covers running, completing, awaiting-input, and any stall except worker-removed, and both stallStepsOnRemovedWorker and stallStrandedStep use it. The removal path then calls requeueResolvedPlacementStalls, which still skips orphaned and moving cards, so only fallback cards restart. slotKeyFor takes the worker from the thread link, so a fallback card whose pin was already cleared still resolves to the removed worker. Leaving a human Pause alone is reasoned and documented. Both new tests (fallback parked step, and a force-move of a usage-limit-parked step) fail with the fix reverted and pass now.
…d signals it already decided Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rker, so it restarts where the card is Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brentkelly
left a comment
There was a problem hiding this comment.
High-level summary — round 3 (re-review at a0bae48)
This PR adds the failure half of the distributed board. Cards on an offline worker pause: their steps are not timed and not recovered, their slots stay held, and they get an amber Worker offline notice. On reconnect, a reconcile scoped to that worker runs before any held signal is released, and the offline interval is credited to the step's timeout. Removing a worker previews the affected cards, falls back the ones with no worktree to the default worker, and holds the rest as placementOrphaned; running steps there are stopped and stalled. Force-move continues a card from its pushed branch, or restarts it from base after a second confirmation. Re-adding the worker reattaches held cards.
Both round-2 fixes hold. decidedThreads now persists per worker across reconcile attempts and is cleared only when the held signals are released or the worker is forgotten. stepBoundToThread widens removal and force-move to parked steps, and removal now requeues the fallback cards. I also re-read the removal decider, BoardWorkers.remove/removePreview, the force-move mover and dialog, the timeout-credit sweep, signal gating, the reattach path, and the web notice/pill code. The four directly relevant test files pass (66 tests). Nothing blocks. This round has 3 nitpicks and no critical or improvement findings.
Status of round-2 findings
| # | prior finding | status |
|---|---|---|
| r2-i1 | 🟡 A partial reconcile forgets which threads it already decided, so held signals are replayed against those steps | ✅ closed |
| r2-i2 | 🟡 Removal and force-move only stall running/completing steps, so a parked step stays bound to a thread on the removed worker | ✅ closed |
| panelTitle: `${name} is offline`, | ||
| panelBody: `Work on this card is paused. Nothing was restarted or moved. It carries on from where it stopped when ${name} reconnects.${ |
There was a problem hiding this comment.
Note
Nitpick (1/3): a revoked or incompatible worker is described as offline and expected to reconnect
boardWorkerOutOfReach counts unauthorized and incompatible as trouble, so those cards get kind: "offline". The panel title is then {name} is offline and the body says the card "carries on from where it stopped when {name} reconnects". The header popover (BoardWorkersAttentionPill.tsx:51) says the same: "They carry on when it reconnects." A revoked worker never reconnects by itself; it needs re-pairing. An incompatible one needs an upgrade. Only the tooltip's why line tells these cases apart, so a human reading the panel may wait for a reconnect that will never happen.
Suggested fix: Pick the title and the closing sentence by worker.status.state. For example, "{name}'s access was revoked — re-pair it in Settings → Workers, or force-move the card", and the same for a T3o upgrade. Do the same in the popover row.
There was a problem hiding this comment.
Fixed in 7e03784. boardCardWorkerTrouble now picks the panel title by status.state: "{name}'s access was revoked", "{name} needs a newer T3o", or "{name} is offline". The body ends "once {name} is paired again", "once {name} is upgraded", or "when {name} reconnects". The header popover row has a new resumesWhen field built by the same helper, so its "They carry on …" line no longer says reconnect for a revoked worker. The short label stays "Worker offline", the notice name the brief fixes. Test: cardWorker.test.ts › "tells a revoked or incompatible worker apart from one that will reconnect".
| const stopsAStep = | ||
| current?.result?.byHand.some((card) => card.detail.endsWith("step running")) ?? false; |
There was a problem hiding this comment.
Note
Nitpick (2/3): "The running step is stopped" depends on the preview's display text
stopsAStep is byHand.some((card) => card.detail.endsWith("step running")). It parses the human-readable detail that BoardWorkers.removePreview builds at BoardWorkers.ts:521. Rewording that string on the server would silently drop the sentence from the dialog.
Suggested fix: Add a running: boolean (or a reason enum) to BoardWorkerRemovePreviewCard and branch on that.
There was a problem hiding this comment.
Fixed in 1cbf1a9. BoardWorkerRemovePreviewCard has a structured running: boolean, set by BoardWorkers.removePreview from the same running/completing check that builds the detail text. stopsAStep now reads card.running. BoardWorkers.test.ts asserts running is true on the busy by-hand row and false on the fallback row.
| {to} creates a fresh worktree from{" "} | ||
| <code className="font-mono">{phase.baseRefName}</code> and the card goes back to the | ||
| start of Build. All code work from {source.label} is abandoned. The brief, plan and | ||
| thread history are kept. |
There was a problem hiding this comment.
Note
Nitpick (3/3): the restart confirmation says the card goes back to Build even when it won't
After the round-1 fix, restartFromBase moves a card back to Build only when it is at or after Build. A pre-Build card keeps its stage and only loses its branch. The confirmation still tells every card that it "goes back to the start of Build".
Suggested fix: Word it as "the card's branch is cut fresh from {base} when it reaches Build", or have the check answer say whether the stage changes and use that.
There was a problem hiding this comment.
Fixed in 8b8ed12, with the wording driven by the check answer as suggested. The no-remote-branch answer has a new backToStage: the Build stage's label for a card at or past Build, using the same isBoardStageAtOrAfterBuild rule restartFromBase applies, or null for a card not yet at Build. The confirmation says "goes back to the start of {stage}" only when backToStage is set. Otherwise it says the card stays in its stage, its branch is forgotten, and Build later starts from base. Tests in BoardCardMove.test.ts: the restart test expects backToStage: "Building", and "a card not yet at Build restarts in its own stage" asserts backToStage: null from check.
…nd waiting to reconnect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not the row's text Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ps its stage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… servers (T3O-60) (#191) * feat(board): distributed board foundation, worker registry and connection (#185) * docs(t3o): rewrite the overview for the distributed board (D19–D22) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): worker registry commands, events and projection The board read model gains the registered workers and the default worker (D8): board.worker.register / remove / set-default, folded by the projector and projected into board_workers and board_default_worker (migration 046). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): connect to workers, and the worker RPCs on both sides The central server dials each registered worker with its own bearer session (WorkerTransport), handshakes over boardWorker.hello, keeps the last-known providers and checkouts, and redials with backoff (WorkerConnections). BoardWorkers adds, re-pairs, removes and defaults workers and clones board projects onto them; its summary rides board.subscribeBoard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): worker registry fold, projection and project mapping Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): worker connection status, redial and last-known snapshot Connection state is a SubscriptionRef, so callers watch status changes without polling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): adding, re-pairing, removing and setting up workers Exchange runs against a real local HTTP server with both credential kinds; every add error comes from its cause; setup clones the origin into the worker's projects folder and classifies failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the live worker transport against a real WebSocket server Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(client-runtime): board workers state and commands Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(client-runtime): mint a worker pairing token with this client's own session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): worker status and checkout copy for the Workers settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): worker summary hook and the pairing-token command Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): Workers and Project checkouts in Settings → Board The worker list with live status, the row menu, the default worker, Add worker (pick a connected environment, or paste a pairing link) and Re-pair, the remove confirmation, and the coverage matrix with Set up on worker and clone failure details. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(user): running the board across several machines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the boundary and backup suites know about workers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): redials keep an offline worker offline instead of flashing connecting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker change between the opening read and going live reaches the client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a clone retry the worker no longer tracks starts a fresh clone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): worker sessions are always given back, and a failed handshake is told apart - A failed add or re-pair after the token exchange hands the new session back and drops a token it stored, instead of leaving it on the worker. - Re-pair gives the replaced session back. - A server without boardWorker.hello answers with a defect; that is now a typed 'unsupported' error (incompatible), while a handshake that fails on the wire is unreachable/offline. - /ws and the ticket URL keep the worker URL's path prefix, like the HTTP calls do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): the board renews its session on each worker before it expires Upstream bearer sessions live a fixed 30 days with no refresh, so every worker turned "Access revoked" after a month. Workers now report the calling session's expiry in hello and answer boardWorker.renew with a successor carrying the caller's own subject, scopes and label. The connection loop renews a week ahead, stores the successor, releases the old session and redials without leaving online. Also: pairing links expire after 5 minutes, not 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): re-pairing from the picker keeps the address the board dials A minted token arrives with the client's own URL for the worker. Re-pair now tries the recorded URL first and only moves to the client's when the recorded one no longer answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): Remove gives access back only once it lands, and Set up claims its clone first Remove dispatched the removal after releasing the session and deleting the token, so a refused removal left a registered worker with no access. The record now goes first. Set up read the cloning guard before the start RPC and wrote it after, so two concurrent calls both cloned; it now claims the setup atomically before going over the network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): type the renewal loop's outcome, and keep the testkit on DateTime Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): only a board's own bearer session can renew itself on a worker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a failed session renewal is retried while the link is up, and an unstored successor is given back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): an interrupted or unanswered Set up no longer leaves a clone spinning for good Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): re-pair moves to the client's address when another server answers at the old one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a Set up abandoned after the worker accepted picks that clone back up Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): board git and forge operations run on the card's worker (#186) * feat(contracts): named boardWorker.git.* operations for the card's worker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): every board git and forge operation runs in the card's workspace BoardWorkspace names each operation; LocalBoardWorkspace runs today's code on this server, RemoteBoardWorkspace sends it to a worker over boardWorker.git.*, and the worker answers with its own local workspace. The supervisor, branch cleanup, reclaim, the orphan sweep, the merge path and the activity actor's user.name all resolve workspaceFor(card), which is local until placement lands. measureBaseTip now fetches the base and reads the remote ref, so a merge from another worker counts as movement; ensureLocalBaseBranch fetches a base the checkout has never seen. A worker the board cannot reach is never a git failure: provisioning keeps its slice and retries without spending an attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the named operations answer the same locally and through a worker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): a card placed on a worker provisions, merges and reclaims there The live reactor with a test placement: every git command for the placed card runs on an in-process worker, an unreachable worker spends no provisioning attempt and reports nothing, a provision dropped mid-way resumes on its recorded slice, and the activity actor's name falls back to the card's worker when this server has no checkout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker refuses a folder under its worktrees dir the project never registered Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): validate worker branch names, classify RPC faults, bound the staleness fetch - boardWorker.git.* payloads take only git-valid branch names, never an option; the operations that create, push or delete take only board/ ones. - A call or answer that will not decode is a BoardWorkerGitError, not WorkerUnavailable; only a lost connection is an outage. - measureBaseTip's fetch waits 15s, not 60s. - The forge operation union is derived from its list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): each board sweeps only its own checkouts on a shared worker; forget settled provisions A checkout the central board provisions on a worker carries an owner marker in its private git dir. The worker's own orphan sweep skips marked checkouts, and the central board's sweep through the worker lists only them. interruptedProvisions drops cards no longer provisioning on each cleanup pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): an unreadable owner marker is in neither sweep; neither board adopts the other's checkout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a gone checkout of the branch is re-provisioned, not refused as another board's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): remote thread gateway and MCP relay (distributed board 2/5) (#187) * feat(contracts): the worker thread gateway, MCP relay and attachment staging RPCs Thread links and thread shells name the worker a thread lives on; absent means the central server itself, so a local-only board is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): the worker side of the remote thread gateway A worker serves its own threads to a central board over one tagged boardWorker.gateway.call plus signal and todo-log streams, records each thread a board spawned there, and holds board tool calls for the board's relay stream. Thread links carry their worker (migration 047), and a thread on an unreachable worker reads as unknown, never as gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): the central side of the remote thread gateway and the MCP relay The board's ThreadGateway becomes a router over this server's threads and every worker's: spawns go to the worker they name, other thread calls to the worker the thread link names, and observe merges every worker's signals, which a per-worker feed follows from a persisted cursor. Board tool calls from a relayed thread are forwarded to the central board, which checks the thread is linked to a card on that worker, runs the same handlers, and answers each call id once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the remote thread gateway against an in-process worker Spawning in the worker's checkout, signals and their cursor, resuming after a dropped connection, unknown on an unreachable worker, routing by thread link, and attachment staging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the MCP relay end to end, through an in-process worker A relayed board_complete_step completes the central card's step; a thread whose card is on another worker is refused; a call delivered twice is applied once; with no board connected the error is retryable; staged files are named by their path on the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): a planning step run on a worker, and the worker's relay half The live supervisor, through the router and the signal feed, spawns a planning step's thread in a worker's checkout behind a test-only placement, decides on it as the worker reports it, and completes the step. The relay host's redelivery and timeout, and the gateway RPCs' encoding and scope, have their own tests. Gateway and tool codecs are compiled once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): card threads open on the worker they ran on A thread row says which worker it ran on when the board has more than one, and opens in that worker's environment. Without a connection to it the thread pane says so, with Settings → Connections opened on the worker's address; Opening…, Offline, Removed and needs-sign-in come from the client's own connection state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(t3o): seams for the worker-thread Connections prefill; threads on a worker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): relay retries, interrupted answers, run restarts and late attachments - A worker's relay host keeps a timed-out call pending; the agent's retry of the same call rejoins it instead of running it again under a new call id. - The central relay client runs each call in its own scope, so a dropped link no longer strands a redelivered call on a dead in-flight answer. - runPerOnlineWorker re-runs a failed run on the same healthy link after a backoff, rather than waiting for a redial. - A relayed board_get_card_context stages the card's current files on the worker before handing out their paths. - Sign in opens Connections without the Add environment prefill, and the addHost param is cleared once consumed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): relay claims a call before its lookup; unknown thread state is retryable A redelivered call claims its in-flight slot before reading the store, so it can no longer run a second time. An unknown thread state answers the retryable board-unreachable, and transient outcomes are not recorded for the call id. A timed-out call nobody retries is dropped after one more timeout, and the per-worker re-run backoff starts over after a run that lasted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a timed-out relay call waits ten timeouts for the agent's retry The sweep dropped a timed-out call one timeout later, about when the agent is told to retry, so a retry following that advice ran under a new call id and could repeat a write the central server had already applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a resumed signal stream warms its reader; an unreadable relay record is not re-run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(board): the signal reader warm-up is a bounded best effort Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): the worker git operations travel as one boardWorker.git.call T3O-64 registered its 25 named operations as an RPC each, and with T3O-63's gateway RPCs the WebSocket group passes what the checker will instantiate (TS2589 in ws.ts). They now travel as one envelope, as the gateway's calls do: each end codes the payload and the answer with the operation's own schema, so the worker still refuses a branch its rules reject before git sees it, and the handler still checks each operation's scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(contracts): format board.ts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): card placement, moving, and per-worker concurrency (#189) * feat(board): card pin, placement and worker moves in the board model BoardCard gains pinnedWorkerId, placedWorkerId and workerMove (migration 048), with board.card.pin-worker (client), board.card.place and the phased board.card.move-worker (internal). Provisioning records the placement it cuts the worktree on; children copy the parent's pin. Step state records which limit holds a queued step, the provider-not-on-worker stall reason lands, and concurrency settings gain per-worker limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): place steps on the card's worker under per-worker limits The supervisor runs each step on the card's effective worker (its worktree's placement, else its pin, else the default) and records the placement before it spawns. A step waits while its worker is offline or still has no checkout of the project (queued: setup), and stalls amber with provider-not-on-worker when the worker lacks its provider instance; a re-pin or the instance appearing puts it back in the queue. Slots are held per (worker, instance), admission needs room under the board, worker and provider ceilings, and a refused step records which one holds it. Card git work runs in the worker's checkout through BoardWorkspacesLive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): move a card to another worker, and pin one from MCP BoardCardMover checks a move (step running, unpushed work with its commits and files, no upstream, source unreachable, target not ready), then retires the old worktree, provisions the new one from the remote branch and records each step on the card. A failed provision leaves Move failed without restoring the old worktree; Retry starts again from the remote. Ask agent to push sends the request to the card's latest thread. All three ride one board.cardWorkerMove RPC. The durability probe now names the unpushed commits, and board_pin_card_worker pins a card from MCP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): card worker field, chip, Move dialog and queue reasons The column card shows its worker in the meta row (solid when bound, dashed when following the default) and Moving / Move failed in its notice slot, and a queued card names the limit holding it once there is more than one worker. The create dialog and the details column gain the Worker field with each worker's load or why it can't be picked and Set up on worker; picking another worker for a card with a worktree opens the Move dialog, which shows what the card continues from, or why it can't move yet. A step stalled for a missing provider restarts when the card's model changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): concurrency settings per worker On a board with workers, Settings → Board → Concurrency shows the board limit, then each worker's own limit with the provider instances it reports below it; an offline worker shows its last-known providers, a revoked or incompatible one says why there is nothing to set, and from six workers on each block collapses to a one-line summary. A provider cap can't exceed its worker limit, and worker limits adding up past the board limit are flagged. A local-only board's section is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): move history on the rail and thread list, worker-only models The activity rail says who pinned a card and each step of a move, and the thread list draws a Moved from {worker} divider above the first thread after a move. The stage and card model pickers also offer provider instances only workers have. Start now says it runs past every limit, and is hidden for a card waiting on its worker's checkout, which no limit holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): clone-failed notice; docs and comments for card placement A card held for its worker's checkout whose clone failed shows Clone failed in amber. The user guide covers choosing a card's worker, moving a card and per-worker concurrency, and comments that still described placement as to come now describe it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): worker shell parity, pin tool by id; Move dialog remounts per target Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): gate re-planned worktree provisioning on placement; keep provider stalls while offline; coalesce worker ticks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a failed retire leaves the card on its source instead of wedging it in Move failed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(contracts): reattach move-check and worktree-check doc comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): Move dialog waits for this move's start; picking the card's own worker pins it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): create dialog clears the worker pin on a project change and warns for a pinned worker without a checkout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep per-worker concurrency visible while a local override outlives the workers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(web): clone progress no longer re-renders every card; detail modal names the project in queue reasons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): re-recording a queued step's limit keeps its thread A resumed step waiting queued on a full board lost its thread when the governor recorded which limit held it, so admission spawned a fresh agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a move cut off by a restart no longer leaves the card Moving forever Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): only a model change restarts a step stalled for a missing provider A title edit, or the decider's dependency rewrite, no longer restarts the stage; a refused restart leaves the stall instead of failing the handler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(contracts): fold the stale concurrency doc comment into BoardConcurrencySettings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): Follow default on a card with a worktree moves it without pinning it A move with no worker goes to the board's default and takes the pin off once it lands, so the card keeps following the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a card moved back to a worker lands on the pushed tip, not a stale local branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(board): say what the restart reconciliation of a move does not cover Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a card moved back after a rebase elsewhere lands on the force-pushed tip The ancestry guard refused any stale local branch not contained in the fetched remote tip, so a rebase + force-push on the other worker left an A→B→A move failing on every retry. Read the remote-tracking ref before the fetch and accept a local tip contained in either it or the new remote tip; refuse only commits that were never pushed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a card moved back after a rebase lands even once the rewritten tip was fetched The pushed check read the remote-tracking ref before the fetch, but the move's own remoteBranchTip check, or any plain fetch on the worker, has already moved it to the rewritten tip by then. Check the local copy against every tip the tracking ref's reflog has held instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): offline workers, reconnect reconciliation, and worker removal (#190) * feat(board): worker removal, force-move and offline gating on the server (wip) Contracts, migration 049, decider, projections, mover and supervisor groundwork for T3O-66: placementOrphaned cards, the worker-removed stall, force-move phases, removePreview, and the supervisor's per-worker reach (held signals, scoped reconcile, timeout credit, deferred probes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): offline worker pause, reconnect reconcile and removal stall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): removal preview, fallback, hold and stop; decider force-move phases Abandoning a removed worker's worktree now ends the card's hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): force-move, re-add and held-signal release Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): Worker removed / Worker offline notices, force-move, removal preview, header indicator Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(board): worker-held cards leave the slot queue; docs for offline, removal and force-move Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a requeued stranded step checks its thread's worker before reading it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker that drops and returns between two syncs is still reconciled Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): force-move tells an unreachable remote from a missing branch, and restarts only cards that can go back to Build Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker reconcile that fails partway still drops the held signals it already decided Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): removal and force-move also stall a step parked on the worker, so it restarts where the card is Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a revoked or outdated worker no longer reads as offline and waiting to reconnect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): Remove worker reads whether a step is running from a flag, not the row's text Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): the restart confirmation says a card not yet at Build keeps its stage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): merge-state wire schema follows #188's narrowed block reasons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): thread release answers per worker and resolves workers in one query An offline or removed worker no longer fails the release sweep for the whole board, and routing a batch of thread ids costs one query rather than one per id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): worker git writes need the central marker; local-only boards read the local base The worker refuses to prune, retire or pin gh on a worktree the central board did not make. A board with no workers measures its base tip from the local ref again, without a network fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): worker last-seen ticks, queue ignores clone progress, Force-move stays closed Also puts running spinners on the blue track, documents the worker-hold amber states, words a failed token mint neutrally, and updates the AGENTS.md status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a dropped spawn is watched not respawned; worker drops free their slot A spawn whose connection drops mid-call links the minted thread so reconnect reconcile decides; a worker unreachable before the spawn holds the step queued as worker-offline and frees the slot. A failure after slot acquire releases it. An unreachable worker's thread no longer reads as a turn with no usage limit, offline intervals are pruned, and a thread on an offline worker stays abandoned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): safer re-pair, removal and moves between workers A failed re-pair restores the worker's previous token. Removing or re-adding a worker forgets its signal cursor. Children inherit a pin only to a known worker, and removing a worker fails any move in flight to it. A move refuses a branch missing from the remote before retiring anything, re-probes for unpushed work right before the retire, and a failed move whose branch is gone can restart from base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): relayed writes and relay streams are bound to their worker and session A relayed write may only touch the caller's card or a card on the relaying worker. Agents lose board_pin_card_worker (D20: pinning is a human act). The worker binds each board to the session that claimed it and accepts answers only from the session a call went to. Timed-out relay calls are tracked per call, stored answers are keyed by worker and call, and a deleted thread drops its relay record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(board): hoist the spawn-failure schema check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): only a dropped spawn call reads as maybe-spawned A worker without the call, or card files that never reached it, left the step running against no thread. Both now stall it as a plain failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a re-pair gives the old session back where the worker now is Released at the pre-move address, the old session stayed live on the moved worker and kept its relay claim, refusing the new session's spawns and relay stream until it expired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker refuses to delete branches the central board doesn't own deleteCardBranch names no path, so the ownership guard never saw it. The worker now refuses a branch on its own board's cards or checked out in a worktree the central board didn't make. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): the distributed planning step waits on the supervisor's read, not yields Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(board): spawn-failure cases as one each-table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a worker dying partway through a spawn reads as maybe-spawned Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a central card never builds on the worker board's branch of the same name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(board): a central split parent never adopts the worker board's integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(board): workerGitRpc header counts its three checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: brentkelly <d-ai@zeald.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Distributed board 5/5 (T3O-66, part of T3O-60). A worker that goes offline currently leaves its cards to time out and recover, and spawn duplicate threads. Removing a worker also has no defined handling for the cards still placed on it.
reconcileWorkerruns before held signals are replayed and beforeschedule(). It never respawns a live thread.board.workers.removePreviewgroups the affected cards. Cards with no worktree fall back to the default worker. The rest becomeplacementOrphaned: running steps are stopped if the worker is reachable, stalled withworker-removed, and their slots released. Re-adding the worker reattaches them.board.cardWorkerMoveacceptsforceandrestartFromBase. The move continues from the remote branch, or restarts from base after a second confirmation.🤖 Generated with Claude Code