Skip to content

feat(board): offline workers, reconnect reconciliation, and worker removal - #190

Merged
brentkelly merged 14 commits into
board/t3o-60from
board/t3o-66
Oct 22, 2026
Merged

brentkelly merged 14 commits into
board/t3o-60from
board/t3o-66

Conversation

@brentkelly

Copy link
Copy Markdown
Owner

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.

  • Offline: signals from a worker that isn't reconciled are held. Timeouts skip steps on a paused worker and credit the time it was offline. Usage-limit probes skip those cards. Slots stay held, and cards show a Worker offline notice with Force-move….
  • Reconnect: a scoped reconcileWorker runs before held signals are replayed and before schedule(). It never respawns a live thread.
  • Removal: board.workers.removePreview groups the affected cards. Cards with no worktree fall back to the default worker. The rest become placementOrphaned: running steps are stopped if the worker is reachable, stalled with worker-removed, and their slots released. Re-adding the worker reattaches them.
  • Force-move: board.cardWorkerMove accepts force and restartFromBase. The move continues from the remote branch, or restarts from base after a second confirmation.
  • Web: notice ranking, the force-move dialog, removal-dialog grouping and the header attention pill. User docs are updated.

🤖 Generated with Claude Code

brentkelly and others added 6 commits October 23, 2026 09:26
…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 brentkelly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +2041 to +2047
yield* dispatch({
type: "board.card.requeue-step",
commandId: yield* commandId("requeue-step"),
cardId: card.id,
preserveBudget: true,
createdAt: yield* nowIso,
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 for loop in schedule(), 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +8869 to +8882
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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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
  • unreachableSince is never set, so the offline time is not credited, and sweepTimeouts can 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +192 to +204
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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +437 to +457
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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1985 to +1988
* 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in f8491c3: the docblock now sits directly above requeueResolvedPlacementStalls.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Adjudication (round 1): fix-upheld. The docblock now sits directly above requeueResolvedPlacementStalls.

brentkelly and others added 3 commits October 23, 2026 10:01
…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 brentkelly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Comment on lines +8902 to +8903
const decidedThreads = yield* reconcileWorker(workerId);
if (decidedThreads === null) continue;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Comment on lines +1957 to +1959
for (const state of boardNonTerminalStepStates(board)) {
if (state.status !== "running" && state.status !== "completing") continue;
if (slotKeyFor(board, state).workerId !== workerId) continue;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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 fallback and 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, and gateThreadSignal drops that worker's signals anyway. Nothing ever requeues it, because requeueResolvedPlacementStalls only picks up worker-removed and provider-not-on-worker stalls.
  • Has a worktree: the card is orphaned and the human force-moves it. stallStrandedStep skips 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: slotKeyFor resolves to the removed worker, so workerPausedNow is 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

brentkelly and others added 2 commits October 23, 2026 10:23
…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 brentkelly left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Comment on lines +382 to +383
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.${

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Comment on lines +392 to +393
const stopsAStep =
current?.result?.byHand.some((card) => card.detail.endsWith("step running")) ?? false;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +180 to +183
{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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

brentkelly and others added 3 commits October 23, 2026 10:33
…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>
@brentkelly
brentkelly merged commit fb68f94 into board/t3o-60 Oct 22, 2026
@brentkelly
brentkelly deleted the board/t3o-66 branch October 22, 2026 21:36
brentkelly added a commit that referenced this pull request Oct 23, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant