Skip to content

feat(board): distributed board foundation, worker registry and connection - #185

Merged
brentkelly merged 27 commits into
board/t3o-60from
board/t3o-62
Oct 21, 2026
Merged

brentkelly merged 27 commits into
board/t3o-60from
board/t3o-62

Conversation

@brentkelly

Copy link
Copy Markdown
Owner

Part of T3O-60, the distributed board. This is the foundation that the later cards build on. It rewrites the overview (D19–D22), adds the worker registry, the central server's own connection to each worker, the worker-side boardWorker.* RPCs, project ↔ worker mapping with Set up on worker, and the Workers and Project checkouts sections in Settings → Board.

With no workers added, the board behaves and renders as it does today.

🤖 Generated with Claude Code

brentkelly and others added 14 commits October 21, 2026 23:39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connection state is a SubscriptionRef, so callers watch status changes
without polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…own session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 lays the foundation for the distributed board. It rewrites the overview (D19–D22) and adds an event-sourced worker registry with its own board migration (046). It adds the central server's own credential exchange and long-lived connection to each worker, the worker-side boardWorker.* handshake, provider and project RPCs, project ↔ worker mapping with clone-based Set up, and the Workers and Project checkouts settings. The layering is clean. Registry commands are internal and reached only through service methods. A local-only board's snapshot and stream stay byte-for-byte unchanged. The decider and rehydration paths match the replay fold. The tests use the test clock rather than sleeps.

Nothing here is critical. This round has five improvements and two nitpicks, and most concern the runtime edges of the connection work, not the registry. Worker status changes can be lost between a subscriber's initial read and its change subscription. A retry-in-place that the worker no longer recognises leaves Set up spinning forever. The redial loop flashes offline workers blue and re-broadcasts every cycle. Failed adds and re-pairs leave stale board sessions on the worker. The nitpicks are a misleading "incompatible" for transient handshake failures and inconsistent URL building between the HTTP and WebSocket paths.

No browser or two-client check was part of this review, matching the build notes.

engine.latestSequence.pipe(
Effect.map((sequence): BoardShellStreamItem => ({ kind: "workers", sequence, state })),
);
const workerDeltas = workers.changes.pipe(Stream.mapEffect(workersItem));

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/5): Worker status can be missed between the initial read and the change subscription

subscribeBoard reads workers.state for the snapshot (line 256) or the resume resync (line 241). Only later, once deltasAfter starts running inside Stream.concat, does it subscribe to workers.changes. BoardWorkers.changes comes from SubscriptionRef.changes(...).pipe(Stream.drop(1)), so it only sees changes made after it subscribes. A status change in that gap (connecting → online while the snapshot loads, or a setup reaching done) is lost. The client keeps the stale value until something else changes, and a healthy worker that stays online never changes again, so its row can show "Connecting" indefinitely. Boot is exactly when every worker is dialing and clients are reconnecting, which makes this likely. The todo stream avoids the same race by subscribing eagerly (yield* threadTail.subscribeTodoChanges), and BoardWorkers.states ("subscribed atomically with the first read") was added for this but is unused.

Suggested fix: Subscribe once, up front, with a states-style stream that yields the current value first. Take its first element as the snapshot/resync value and feed the rest as workerDeltas. Alternatively, Stream.toPubSub the changes before the initial read. Add a subscription test that flips a worker's status between the snapshot read and the first delta.

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 a697da6. The subscription now subscribes to BoardWorkers.states, which opens with the summary as of when the stream starts. Any change made after the opening read therefore reaches the client. Items are compared with Schema.toEquivalence(BoardWorkersState) against what the client already holds, so an unchanged summary isn't re-sent and a local-only board's stream stays byte-for-byte as before. BoardWorkers.changes is now unused and has been removed. Test: boardSubscription.test.ts › "sends a worker change that lands between the opening read and going live". It fails on the old code, which gives [snapshot, card-upserted] with no workers item.

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. In a697da6 the subscription now runs workers.states. That stream reads the summary when it subscribes (SubscriptionRef.changes), and its first value is compared against the state the client was actually sent. A change that lands in the gap therefore differs from that state and gets sent. The new test 'sends a worker change that lands between the opening read and going live' feeds a stale opening state and a fresh states and asserts the extra workers item. It passes on HEAD and fails on 1425bc8. One narrow leftover, not blocking: changesWith dedupes against the last value this stream emitted, not against what a log-path workers item may have sent since. Both paths read live state, so they converge on the next runtime change.

Comment on lines +606 to +614
const start: Effect.Effect<unknown, WorkerRpcError> = retryInPlace
? rpc.retryProjectClone(workerProjectId)
: rpc.startProjectClone({
projectId: workerProjectId,
title: project.title,
createdAt: startedAt,
destinationPath,
remoteUrl,
});

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/5): Retry-in-place ignores applied: false and leaves the setup stuck on "cloning"

When retryInPlace is true, start is rpc.retryProjectClone(...), which resolves to the tracker's applied boolean. The result is never checked. Upstream ProjectCloneTracker.retry returns false when it no longer tracks the clone (it is in-memory, so a worker restart drops it) or the clone isn't failed or cancelled. The setup is still marked cloning and follow subscribes to projectClones. follow filters for workerProjectId, which never appears, so runForEachWhile never ends while the connection is up. The Project checkouts cell shows a blue spinner until the worker disconnects or someone dismisses it. Example: a clone fails with no-credentials, the worker restarts (the setup row on the central server stays failed), the human fixes credentials and clicks Retry, and it spins forever. The same hang happens if the tracked clone disappears from the worker's list for any other reason, such as being discarded on the worker.

Suggested fix: Treat applied === false as "start a fresh clone" (fall through to startProjectClone with a new id), or fail the setup straight away. In follow, end with a failure once an emission no longer contains a clone that was seen before, or when the first emission doesn't contain it at all.

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 58533e9. If retryProjectClone returns applied: false, setupProject now starts a fresh clone with a new worker project id and follows that id. Test: BoardWorkers.test.ts › "a retry the worker no longer tracks starts a fresh clone instead of hanging". It fails on the old code, which never started a second clone. The fake worker gained retryApplied to drive 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. In 58533e9, retryProjectClone returning applied: false now falls through to startFresh with a new project id, and that id is followed. Because workerProjectId comes from started.success, the follow can no longer wait on an id the worker never emits. The test 'a retry the worker no longer tracks starts a fresh clone instead of hanging' passes on HEAD and fails on the old code.

const attempt = (worker: BoardWorker): Effect.Effect<AttemptOutcome> =>
Effect.scoped(
Effect.gen(function* () {
yield* setState(worker.id, "connecting");

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/5): Every redial flips an offline worker to blue "Connecting" and broadcasts it to every client

attempt begins with setState(worker.id, "connecting") on every loop iteration, including redials of a worker that is already offline. For an unreachable host that times out instead of refusing, the ticket request (10 s) and socket open (10 s) keep it connecting for up to about 20 s of every ~50 s cycle. The Workers list and card notices then alternate amber "Offline" and blue "Connecting". docs/t3o/status-colours.md reserves blue for running work, and AGENTS.md specifically warns against a lying spinner. Each flip is also a SubscriptionRef change, so BoardWorkers.changes pushes the whole workers state to every board subscriber twice per cycle, per offline worker, indefinitely.

Suggested fix: Only set connecting on the first dial and after restart/re-pair. On redial from offline/incompatible, stay in the current state until the attempt resolves. If a "retrying" hint is needed, use a non-status field that the UI doesn't colour blue.

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 bbc5758. A redial of a worker that is already offline or incompatible keeps that status until the worker answers. Blue connecting is only for a first dial or a re-pair. update now skips publishing when an entry is unchanged (SubscriptionRef.updateSome), so the retry loop no longer broadcasts the same offline state each cycle. Test: WorkerConnections.test.ts › "redials an offline worker quietly: it stays offline, never blue connecting". It sees only [offline] over 2 minutes of redials, then [offline, online]. The old code produced [offline, connecting, …(13)].

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. In bbc5758, markDialing leaves an offline or incompatible entry as it is. update now uses updateSome and setState checks every field, so an unchanged entry is never published. A dropped worker goes to offline and stays there across redials. unauthorized stops the loop, so it can't flash either. The test 'redials an offline worker quietly' passes on HEAD and fails on the old code.

Comment on lines +244 to +261
const probed: { readonly hello: BoardWorkerHello; readonly snapshot: BoardWorkerSnapshot } =
yield* probe({
url: input.httpBaseUrl,
accessToken,
workerLabel: input.workerLabel,
workerVersion: input.workerVersion,
});
yield* secrets.set(workerSecretName(input.workerId), encodeWorkerToken(accessToken)).pipe(
Effect.mapError(
() =>
new BoardWorkerAddError({
code: "unreachable",
message: "The board server couldn't store the worker's credential.",
}),
),
);
return probed;
});

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 (4/5): A failed add or re-pair after the exchange leaves an orphaned board session on the worker

pair spends the one-time token in exchangeWorkerCredential and then probes. probe gives the session back only when it gets the incompatible result. It doesn't release the session when readWorkerSnapshot fails ("stopped answering while being added"), when transport.connect fails after the exchange, or when secrets.set fails. In add, the secret is also written before engine.dispatch(board.worker.register). If that dispatch is refused (for example, two concurrent adds of the same environment both pass the existing check), the token stays in ServerSecretStore under that id, and the worker keeps a live T3o board (...) session that the board doesn't track. The worker's Connections list grows a stale client with standard scopes that only a human revoking it on the worker can clean up. That is a one-way door the brief's "Ways back" section is meant to avoid.

Suggested fix: Once the exchange succeeds, wrap the rest of pair/add in an Effect.onError that releases the new session (a best-effort release over a fresh link, or revokeSession if reachable) and removes the secret if it was written. Add a BoardWorkers test where the snapshot read fails after the exchange and assert the session is released.

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 6db4fae. In pair, everything after the exchange (the probe, the snapshot read, storing the secret) now runs under Effect.onError(abandonPairing). That dials with the new session and calls release, then removes the stored token only if it is still ours, so a concurrent add's token is never deleted. A failed board.worker.register dispatch in add and in re-pair goes through the same path. Test: BoardWorkers.test.ts › "a handshake that fails on the wire is unreachable, and its new session is given back". It asserts releasedTokens == [access-blip], no secret stored and no worker registered. It fails on the old code.

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. In 6db4fae, pair wraps the probe and secrets.set in Effect.onError(abandonPairing), and add and re-pair both wrap their register dispatch the same way. abandonPairing releases the new session and removes the stored secret only if it still holds this token, so a concurrent successful pairing isn't clobbered. Every failure point after the exchange and before the record lands is now covered. The test 'a handshake that fails on the wire is unreachable, and its new session is given back' passes on HEAD and fails on the old code.

Comment on lines +371 to +377
const probed = yield* pair({
workerId: worker.id,
httpBaseUrl: target.httpBaseUrl,
token: target.token,
workerLabel: worker.label,
workerVersion: descriptor.serverVersion,
});

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 (5/5): Re-pair overwrites the stored token without releasing the session it replaces

repair calls pair, which writes the new access token over workerSecretName(worker.id). The previous session is never released. When the old session is already revoked (the "Access revoked" case) that is fine. Re-pair is also offered from the row menu for online, offline and incompatible workers, and repair accepts a new URL. In every one of those cases the old session is still valid on the worker, so each re-pair adds another T3o board (...) client to the worker's Connections. The board no longer holds the token, so it can never revoke it, and Remove later releases only the newest one.

Suggested fix: Before overwriting, if the worker is currently online, call release on the existing connection (best effort, with RELEASE_TIMEOUT, as remove does). Otherwise accept the leak but say so in the Re-pair dialog copy. Cover the online re-pair case in BoardWorkers.test.ts.

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 6db4fae. Re-pair reads the previous token before pairing. Once the new session is recorded, it releases the previous one by dialing the old URL with the old token, as a best effort with a timeout. If the old session was already revoked, the dial just fails and is ignored. Test: BoardWorkers.test.ts › "re-pairing a worker whose session still works gives the old session back". It checks that releasedTokens == [access-one-time], that the stored token is the new one and that the redial uses it. It fails on the old code.

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. In 6db4fae, re-pair reads the previous token before pair overwrites it. After the new record and connection are in place, it releases that token against the old worker.url, then restarts the connection. The worker revokes 250 ms after it answers release, and restart follows straight away on the new token, so the dial isn't left on a dead session. The test 're-pairing a worker whose session still works gives the old session back' passes on HEAD and fails on the old code.

Comment on lines +209 to +213
const hello = yield* link.rpc.hello.pipe(Effect.option);
if (Option.isNone(hello) || hello.value.protocolVersion < BOARD_WORKER_PROTOCOL_FLOOR) {
yield* link.rpc.release.pipe(Effect.timeout(RELEASE_TIMEOUT), Effect.ignore);
return yield* incompatible;
}

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/2): Any failed hello is reported as "incompatible", including a transient drop

link.rpc.hello.pipe(Effect.option) turns every WorkerRpcError into "this server doesn't speak the board-worker protocol". That includes the socket closing or the ping timing out right after it opened. In the connection loop this resolves itself on the next dial. In the add flow, though, the user is told "{worker} runs T3o X. This board needs 0.0.45 or newer" for what was really a network blip, and the new session is released. The same applies at WorkerConnections.ts:204.

Suggested fix: Tell the two apart. Only an RPC-level "unknown method"/decode failure means incompatible. A transport-level failure means unreachable (add) or offline (loop).

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 6db4fae. Fixing this turned up a related bug. A server without boardWorker.hello (upstream) answers with an RPC defect (Unknown request tag), not a typed error. Effect.option doesn't catch defects, so the old code would have died in both the add probe and the connection loop. The transport now maps every worker call through call(), which turns typed failures into WorkerRpcError and defects into WorkerRpcError { unsupported: true }. Add and the connection loop treat unsupported as incompatible and any other hello failure as unreachable/offline. Tests: WorkerTransport.test.ts › "a server with no board-worker handshake fails hello as unsupported" (against a real RPC server without the hello rpc), BoardWorkers.test.ts › "a handshake that fails on the wire is unreachable…", and WorkerConnections.test.ts › "a handshake that fails on the wire is offline, not incompatible". All three fail on the old code.

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. In 6db4fae, worker calls map RPC defects, which is how a server answers an unknown tag, to WorkerRpcError{unsupported:true}. Only that error counts as incompatible. Other hello failures become unreachable in add and offline in the connection loop. This also removes the defect that would have escaped Effect.option. The tests 'a server with no board-worker handshake fails hello as unsupported' and 'a handshake that fails on the wire is offline, not incompatible' both pass on HEAD and fail on the old code. The second hangs to its 120 s timeout there.

Comment on lines +98 to +100
const toWsUrl = (url: string, ticket: string): string => {
const next = new URL("/ws", url);
next.protocol = next.protocol === "https:" ? "wss:" : "ws:";

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/2): /ws URL drops any path prefix the worker URL carries, unlike the HTTP calls

toWsUrl and requestTicket resolve /ws and /api/auth/websocket-ticket with new URL("/...", url), which replaces the path. workerCredential.ts builds ${httpBaseUrl}/oauth/token and ${httpBaseUrl}/.well-known/t3/environment, which keeps it. A worker behind a reverse proxy at https://host/t3/ would pass the add flow's descriptor and exchange steps, then fail to connect.

Suggested fix: Build all worker URLs the same way, e.g. new URL("ws", url.endsWith("/") ? url : url + "/"), or reject URLs with a path in resolveWorkerTarget.

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 6db4fae. /ws and /api/auth/websocket-ticket are now joined onto the worker URL with workerPath, which appends to the URL the same way workerCredential.ts does, so a path prefix is kept. Test: WorkerTransport.test.ts › "a worker served under a path prefix is dialed under that prefix", which serves a real worker under /t3. It fails on the old code.

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. In 6db4fae, workerPath joins the path onto the trimmed base URL, which keeps the prefix. It's used for both /ws and /api/auth/websocket-ticket, matching workerCredential.ts. The test 'a worker served under a path prefix is dialed under that prefix' passes on HEAD and fails on the old code.

brentkelly and others added 4 commits October 22, 2026 00:39
…g connecting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eaches the client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…clone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hake 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>

@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 6db4fae)

This PR lays the distributed board's foundation. It rewrites the overview (D19–D22), adds the event-sourced worker registry with its migration, the central server's own session and live connection to each worker, the worker-side boardWorker.* handshake, and Set up on worker. On the web side it adds the Workers and Project checkouts settings. The round-1 fixes hold. I re-read each one at the line and found no regressions.

Looking past the diff this round, I found two improvements and three nitpicks. Improvement 1: upstream issues every bearer session with a fixed 30-day TTL and offers no refresh. As a result, every worker the board pairs will turn "Access revoked" about a month after it was added, and recovering it needs a manual re-pair. Improvement 2: re-pairing through the picker replaces the board's working URL for the worker with whatever URL the browser uses to reach it. The nitpicks are the "10 minutes" link-expiry copy (the server default is 5), the order of the steps in Remove, and a check-then-act race in setupProject. The local-only path is unchanged.

Status of round-1 findings

# Prior finding Status
I1 🟡 Worker status can be missed between the initial read and the change subscription ✅ closed
I2 🟡 Retry-in-place ignores applied: false and leaves the setup stuck on cloning ✅ closed
I3 🟡 Every redial flips an offline worker to blue Connecting ✅ closed
I4 🟡 Failed add/re-pair after the exchange orphans a session on the worker ✅ closed
I5 🟡 Re-pair overwrites the stored token without releasing the old session ✅ closed
N1 🟢 Any failed hello is reported as incompatible ✅ closed
N2 🟢 /ws URL drops any path prefix ✅ closed

Comment on lines +136 to +162
export function exchangeWorkerCredential(input: {
readonly target: WorkerTarget;
/** Shown on the worker under Settings → Connections → Clients. */
readonly clientLabel: string;
}): Effect.Effect<{ readonly accessToken: string }, BoardWorkerAddError, HttpClient.HttpClient> {
return Effect.gen(function* () {
const body = new URLSearchParams({
grant_type: AuthTokenExchangeGrantType,
subject_token: input.target.token,
subject_token_type: AuthEnvironmentBootstrapTokenType,
requested_token_type: AuthAccessTokenType,
client_label: input.clientLabel,
client_device_type: "bot",
});
const response = yield* requestWorker(
HttpClientRequest.post(`${input.target.httpBaseUrl}/oauth/token`).pipe(
HttpClientRequest.setHeader("accept", "application/json"),
HttpClientRequest.bodyText(body.toString(), "application/x-www-form-urlencoded"),
),
).pipe(Effect.mapError(() => unreachable(input.target.httpBaseUrl)));
if (response.status >= 400 && response.status < 500) return yield* invalidCredential();
if (response.status !== 200) return yield* unreachable(input.target.httpBaseUrl);
const decoded = yield* decodeAccessToken(response.body).pipe(
Effect.mapError(() => invalidCredential()),
);
return { accessToken: decoded.access_token };
});

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): The board's session on every worker expires after 30 days and is never renewed

exchangeWorkerCredential spends the pairing token on /oauth/token. Upstream's EnvironmentAuth then issues an ordinary bearer session through sessions.issue with no ttl, so SessionStore's DEFAULT_SESSION_TTL = Duration.days(30) applies. Nothing on either side refreshes it: the exchange returns no refresh token, and the board never re-issues its session. Thirty days after a worker is added, requestTicket gets a 401 and WorkerConnections marks the worker unauthorized. That is a terminal state with no redial, and the UI shows it as "Access revoked". So every worker drops off the board on a fixed schedule, and once later plans place cards on workers, those cards stall until a human re-pairs. The UI also blames a revocation that never happened.

Suggested fix: Have the central server renew its own session before it expires. One way is a boardWorker.renew RPC on the worker that issues a fresh session for the caller, then revokes the old one, called when the stored session nears expiry. Store the expiry beside the token so the board knows when. At minimum, record the expiry, tell "expired" apart from "revoked" in the status and copy, and document the 30-day limit in docs/user/board-workers.md.

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 9a61336 (+ 273d5b9). The board now renews its own session instead of expiring:

  • boardWorker.hello reports the calling session's sessionExpiresAt.
  • New boardWorker.renew issues a successor with the caller's own subject, scopes and label, so a session can extend itself but never widen itself. The old session stays valid until the caller gives it back.
  • WorkerConnections holds an online link until it drops or until SESSION_RENEW_AHEAD (7 days) before expiry. At that point it renews, stores the successor, releases the old session, and redials without leaving online (no blue connecting). If the renewal fails, the old token stays in place.

The remaining limit is a board server that was off for longer than the rest of the lifetime: it comes back unauthorized and needs a re-pair. That case is now in docs/user/board-workers.md.

Tests: WorkerConnections.test.ts 'renews the board's session before it expires, and stays online throughout' hangs on the old code, because no renewal ever happens. workerRpc.test.ts 'renew issues a successor with the caller's own subject, scopes and label' and 'hello reports when the calling session expires' cover the worker side.

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.

Adjudicate round 2: fix-upheld. At 273d5b9, hello carries sessionExpiresAt from the caller's session. WorkerConnections.hold sleeps until 7 days before expiry, then calls boardWorker.renew. Upstream's issueSession issues the successor with the caller's own subject, scopes and label, so it can't widen access. The board stores the new token, releases the old session and redials with the status left at online. A failed renewal keeps the old token, waits for the link to drop, and on redial the delay is 0, so it retries without a hot loop. I ran 'renews the board's session before it expires, and stays online throughout' and it passes. It checks the renew, the release, the redial on token-1-renewed, the stored secret and the online-only states.

Comment on lines +216 to +222
return;
}
await submit({
credential: { kind: "pairing-token", token: minted.value.token },
url: minted.value.httpBaseUrl,
expectedEnvironmentId: environment.environmentId,
});

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): Re-pair from the picker replaces the board's working worker URL with this browser's URL

In repair mode, submitPicked passes url: minted.value.httpBaseUrl to repairWorker. That value is prepared.httpBaseUrl, the address this device uses to reach the worker (see mintWorkerPairingToken). BoardWorkers.repair then uses input.url ?? worker.url, so the browser's URL wins. A re-pair is usually needed because the session was revoked, not because the address changed. The board's recorded URL (a LAN address, say) still works, but the browser may reach the worker another way: a Tailscale hostname, localhost on the same machine, or a URL the board server can't resolve. In that case describe() fails with "can't reach", and the re-pair is refused even though the old URL would have worked. If the board can resolve the browser's URL but it leads somewhere less stable, the board's dial URL silently moves to it.

Suggested fix: In repair mode, don't send the minted httpBaseUrl, so the server keeps worker.url. Or have repair try worker.url first and fall back to the client's URL only if the old one is unreachable.

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 030c3db, on the server so it doesn't matter which client re-pairs. A minted token works at any of the worker's addresses, so repair now tries the recorded worker.url first. It moves to the client's URL only when the recorded one is unreachable, so a worker that really moved can still be re-paired from the picker. A pasted link still uses its own address.

Test: BoardWorkers.test.ts 're-pairing from the picker keeps the address the board already dials' fails with unreachable on the old code.

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.

Adjudicate round 2: fix-upheld. In 030c3db, a minted pairing-token re-pair with a different input.url tries worker.url first. It moves to the client URL only on an unreachable error. A pasted link still uses its own address. I put the old BoardWorkers.ts back and 're-pairing from the picker keeps the address the board already dials' fails. It passes on HEAD.

Comment thread docs/user/board-workers.md Outdated
Comment on lines +29 to +31
this device can't create a token there. In that case, and for a server this device isn't connected
to at all, run `t3 pair` on the worker machine and paste the link it prints. Links work once and
expire after 10 minutes.

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): Pairing links expire after 5 minutes, not 10

The docs, and the invalidCredential copy in workerCredential.ts ("Pairing links work once and expire after 10 minutes"), say 10 minutes. Upstream's PairingGrantStore uses DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5) for user-issued links unless --ttl is passed. A user told they have 10 minutes will see links expire early.

Suggested fix: Say 5 minutes in both places, or word it without a number ("expire after a few minutes").

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 9a61336. PairingGrantStore's DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES is 5 minutes. The docs, the server's invalidCredential message and the web workerCopy body now all say 5 minutes.

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.

Adjudicate round 2: fix-upheld. The docs at line 31, the server's invalidCredential copy and the web workerCopy all say 5 minutes now. That matches DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5) in PairingGrantStore.

Comment on lines +450 to +465
// Give the session back first, while the connection that can do it is
// still up. Unreachable is fine: the human can revoke it on the worker.
const revoked = yield* connections.get(workerId).pipe(
Effect.flatMap((rpc) => rpc.release.pipe(Effect.timeout(RELEASE_TIMEOUT))),
Effect.as(true),
Effect.orElseSucceed(() => false),
);
yield* secrets.remove(workerSecretName(workerId)).pipe(Effect.ignore);
yield* engine
.dispatch({
type: "board.worker.remove",
commandId: yield* newCommandId,
workerId,
createdAt: yield* nowIso,
})
.pipe(Effect.mapError((cause) => actionError("internal", cause.message)));

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): Remove gives the session back and deletes the token before the decider has accepted the removal

remove checks the default worker, releases the session on the worker, and deletes the secret. Only then does it dispatch board.worker.remove. If the decider refuses the command, the worker stays registered with a revoked session and no stored token, so it turns unauthorized until someone re-pairs it. That can happen if a concurrent setDefault made this worker the default in between: the decider re-checks the default and refuses.

Suggested fix: Read the worker's rpc with connections.get first, dispatch the remove, then release and delete the secret. The entry stays in connections until syncConnections runs, so the same connection can still carry release.

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 899d9c4. remove now captures the live rpc, dispatches board.worker.remove first, then releases the session over the connection it still holds (until the sync), and only then deletes the token. A refused removal leaves the session and the token intact.

Test: the remove test in BoardWorkers.test.ts now checks that the worker is no longer registered when the release arrives, through a new onRelease hook on the fake worker. It fails on the old code.

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.

Adjudicate round 2: fix-upheld. remove now captures the connection as an Option, dispatches board.worker.remove, and only then releases over the held connection and deletes the secret, before syncConnections. A refused dispatch fails before either side effect. I put the old BoardWorkers.ts back and the updated remove test, which checks through onRelease that the record is gone first, fails. It passes on HEAD.

Comment on lines +620 to +685
const key = setupKey(worker.id, project.id);
const previous = (yield* SubscriptionRef.get(setups)).get(key);
if (previous?.phase === "cloning") return previous;
const destinationPath =
input.folder ?? cloneDestination(worker.snapshot?.projectsFolder ?? "~", remoteUrl);
const startedAt = yield* nowIso;

// A failed clone the worker still tracks is retried in place; anything
// else (a new folder, a folder that existed) starts a fresh clone.
const retryId =
previous?.phase === "failed" &&
previous.destinationPath === destinationPath &&
previous.failure?.kind !== "folder-exists" &&
previous.failure?.kind !== "worker-offline"
? previous.workerProjectId
: null;
const setup: BoardWorkerSetup = {
projectId: project.id,
workerId: worker.id,
workerProjectId: null,
destinationPath,
phase: "cloning",
stage: null,
percent: null,
failure: null,
startedAt,
endedAt: null,
};
const startFresh = Effect.gen(function* () {
const projectId = ProjectId.make(yield* newUuid);
yield* rpc.startProjectClone({
projectId,
title: project.title,
createdAt: startedAt,
destinationPath,
remoteUrl,
});
return projectId;
});
// The worker may have forgotten the failed clone (a restart, say): then
// `applied` is false and it is cloned afresh instead.
const start: Effect.Effect<ProjectId, WorkerRpcError> =
retryId === null
? startFresh
: rpc
.retryProjectClone(retryId)
.pipe(Effect.flatMap((applied) => (applied ? Effect.succeed(retryId) : startFresh)));
const started = yield* Effect.result(start);
if (started._tag === "Failure") {
const cause = started.failure.cause;
const detail =
typeof cause === "object" && cause !== null && "detail" in cause
? String((cause as { readonly detail: unknown }).detail)
: started.failure.message;
const failedSetup: BoardWorkerSetup = {
...setup,
phase: "failed",
endedAt: yield* nowIso,
failure: failed(worker.label, detail),
};
yield* putSetup(failedSetup);
return failedSetup;
}
const workerProjectId = started.success;
const cloning: BoardWorkerSetup = { ...setup, workerProjectId };
yield* putSetup(cloning);

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): Two concurrent Set up calls for the same project and worker both start a clone

The "already cloning" guard reads setups at line 621. The cloning entry is only written at line 685, after the startProjectClone / retryProjectClone round trip. If two clients (or a double click that gets past the disabled button) call this together, both pass the guard and both start a clone into the same destinationPath. The second fails as folder-exists, and its result overwrites the first setup's entry, while the first clone's follower is replaced in FiberMap.

Suggested fix: Claim the key before going over the network: write a cloning placeholder with SubscriptionRef.modify that returns the existing entry if one is already cloning. Or serialize per key with a small semaphore.

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 899d9c4. setupProject claims the key with SubscriptionRef.modify before any network call: it reads the previous entry and writes the cloning entry atomically, and a concurrent second call returns the in-progress setup. Test: BoardWorkers.test.ts 'two Set up calls at once for the same project and worker start one clone' holds the worker's clone start on a Deferred. It fails on the old code with two starts.

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.

Adjudicate round 2: fix-upheld. setupProject now claims the key with SubscriptionRef.modify before any RPC, and a second caller sees cloning and joins. Both the failure and success paths overwrite the claimed entry through putSetup, so nothing gets stuck in cloning. I put the old BoardWorkers.ts back and 'two Set up calls at once for the same project and worker start one clone' fails. It passes on HEAD.

brentkelly and others added 4 commits October 22, 2026 01:15
…ires

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>
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>
…laims 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>
…DateTime

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 273d5b9)

This PR lays the distributed-board foundation. It rewrites the overview (D19–D22), adds an event-sourced worker registry, the central server's own supervised connection to each worker, the worker-side boardWorker.* RPCs, project ↔ worker checkouts with Set up, and the Workers and Project checkouts settings. The round-2 fixes are in and do what they claim. The re-pair URL preference, Remove's dispatch-then-release order and the 5-minute copy are correct. The renewal design is sound: Effect splits long sleeps past the 2³¹ ms timer cap, so the roughly 23-day wait is safe. Two of the new pieces have edges, though. The new boardWorker.renew RPC is open to every read-scoped session, so a browser cookie or DPoP session can swap itself for an unbound bearer token. Renewal is tried once per link and never retried. The atomic Set up claim can be left behind as a permanent cloning entry. That makes 3 improvements and 1 nitpick this round, and no criticals. No human comments are open on the PR.

Status of round-2 findings

# Prior finding Status
I1 🟡 The board's session on every worker expires after 30 days and is never renewed ⚠️ partially addressed. Renewal works on the happy path. A failed renewal is never retried on a live link (Improvement 2/3), and the new RPC is too broadly reachable (Improvement 1/3).
I2 🟡 Re-pair from the picker replaces the board's working worker URL with this browser's URL ✅ closed (a narrow edge is left as Nitpick 1/1)
N1 🟢 Pairing links expire after 5 minutes, not 10 ✅ closed
N2 🟢 Remove gives the session back before the decider accepts the removal ✅ closed
N3 🟢 Two concurrent Set up calls both start a clone ✅ closed. The claim adds a stuck-entry path (Improvement 3/3).

Comment on lines +173 to +188
[BOARD_WORKER_WS_METHODS.renew]: (_input: Record<string, never>) =>
authorized(
BOARD_WORKER_WS_METHODS.renew,
Effect.gen(function* () {
const service = yield* auth;
const sessions = yield* service.listSessions();
const label = sessions.find((session) => session.sessionId === currentSession.sessionId)
?.client.label;
const issued = yield* service.issueSession({
subject: currentSession.subject,
scopes: currentSession.scopes,
...(label === undefined ? {} : { label }),
});
return { accessToken: issued.token };
}).pipe(Effect.mapError(requestError("Couldn't renew the board's session."))),
),

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): boardWorker.renew lets any read-scoped session swap itself for a fresh bearer token, including browser cookie and DPoP sessions

The handler only checks AuthOrchestrationReadScope, then calls issueSession with the caller's subject and scopes. issueSession always mints a bearer-access-token session with deviceType: "bot" and the default 30-day TTL (EnvironmentAuth.ts:1107). Every T3o server answers boardWorker.*, so any client on any T3o server can call it, not only a board. Two consequences:

  • A browser session lives in an httpOnly cookie, which page script cannot read. Over the WebSocket, page script can now call renew and get the same scopes back as a plain bearer token in JS. For the owner's browser that is administrative scopes. A cookie-only credential becomes one that can be carried away.
  • A DPoP-bound session (proofKeyThumbprint) gets a successor with no proof-of-possession binding.

The comment says a session "can only extend itself, never widen itself". That holds for scopes, but the binding and the transport are both weakened. Only the board's own bearer session needs this RPC.

Suggested fix: Refuse renew unless currentSession.method === "bearer-access-token" and there is no proofKeyThumbprint. Better still, also require the session to be a board session, for example by its T3o board ( label or a dedicated subject set at exchange time. Add a workerRpc.test.ts case where a cookie session is refused.

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 be2ba45. boardWorker.renew now refuses unless the caller is a plain bearer-access-token session with no proofKeyThumbprint and a board label (isBoardSessionLabel, the counterpart of boardSessionLabel in workerCredential.ts). A browser cookie, a DPoP-bound session or any other bearer session gets a BoardWorkerRequestError and nothing is issued. Test: workerRpc.test.ts "renew refuses a browser cookie, a DPoP-bound or a non-board session" (fails on the old code, which issued all three).

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 3): fix-upheld. be2ba45 refuses renew unless currentSession.method === "bearer-access-token", there's no proofKeyThumbprint, and the label passes isBoardSessionLabel. The successor reuses the same label. workerRpc.test.ts 'renew refuses a browser cookie, a DPoP-bound or a non-board session' fails on 273d5b9 and passes at 63dd84c.

Comment on lines +255 to +261
const due = yield* Effect.raceFirst(
link.closed.pipe(Effect.as(false)),
Effect.sleep(delay).pipe(Effect.as(true)),
);
if (due && (yield* renewSession(worker.id, link.rpc))) return { _tag: "renewed" } as const;
yield* link.closed;
return { _tag: "dropped" } 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): A failed renewal is never retried while the link stays up, so the session can still expire

hold sleeps until 7 days before expiry and tries renewSession once. If that fails (a transient RPC error, the worker busy, or secrets.set failing), it falls through to yield* link.closed and does nothing until the socket drops. A stable link can last the whole remaining 7 days. Upstream does not close a socket when its session expires (only revocation closes it, SessionStore.ts:227), so the board stays green until the next drop. Then the redial gets a 401 and the worker goes terminal unauthorized ("Access revoked"), which needs a manual re-pair. That is the outcome round-2 I1 was meant to prevent. The current test covers only the success path.

Also, when renew succeeds but secrets.set fails, catchCause returns false and the new session is left behind on the worker.

Suggested fix: Retry inside hold: on failure, race link.closed against a backoff sleep (minutes to an hour, capped before expiresAt) and try again. If the successor's token can't be stored, release it. Add a test where the first renew fails and the second succeeds on the same link.

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 f617008. hold now loops: after a failed renewal it waits sessionRenewalRetryDelay (1 minute, doubling, capped at an hour, well inside the 7-day lead) and tries again, for as long as the link stays up. If renew succeeds but secrets.set fails, the successor is given back over a short link of its own (release revokes the session that calls it) and the old token stays in use. Tests: WorkerConnections.test.ts "retries a failed renewal while the link stays up" and "gives back a successor it couldn't store, and keeps the old token". Both fail on the old code.

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 3): fix-upheld. In f617008, hold now loops while the link is up and retries with sessionRenewalRetryDelay (1 min, doubling, capped at 60 min, so it stays well inside the 7-day lead). When secrets.set fails, the successor is released over its own link and the old token is kept. Both new WorkerConnections.test.ts tests fail on the old code and pass now.

Comment on lines +659 to +666
const previous = yield* SubscriptionRef.modify(
setups,
(map): readonly [BoardWorkerSetup | undefined, Map<string, BoardWorkerSetup>] => {
const existing = map.get(key);
return [existing, existing?.phase === "cloning" ? map : new Map(map).set(key, setup)];
},
);
if (previous?.phase === "cloning") return previous;

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): Set up's claimed cloning entry is never cleared if the call is interrupted or dies before the worker answers

The round-2 N3 fix writes a cloning entry with workerProjectId: null before rpc.startProjectClone / retryProjectClone. Only the Effect.result failure branch and the success branch replace it. Some exits skip both:

  • The handler fiber is interrupted, for example when the client disconnects or the request is cancelled during the round trip.
  • A defect escapes. Effect.result captures typed failures only.
  • The worker call never returns. call in WorkerTransport.ts has no timeout, so a half-open worker socket hangs it.

In all three, no follower is started, so nothing ever moves the entry on. The matrix shows a spinner that never stops (ProjectCheckoutsSection.tsx cloning has no Dismiss), and every later Set up for that pair returns the stale entry at line 666 instead of cloning. Only removing the worker or restarting the board server clears it. Before this fix the same interruption left nothing behind.

Suggested fix: Guard the section from the claim until putSetup with Effect.onExit, or Effect.onInterrupt plus catchDefect. If the claimed entry is still the one this call wrote, drop it or mark it failed. Give the start/retry call a timeout. Add a test that interrupts the Set up mid-RPC and checks that a second Set up then starts a clone.

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 the third commit of this round (fix(board): an interrupted or unanswered Set up…). Everything after the claim runs under Effect.onExit(abandonClaim). If the entry is still the exact claimed object when the call ends (interrupt, defect, anything), it becomes failed with kind worker-offline, so Dismiss works and the next Set up starts fresh. The start or retry RPC now has a 30-second SETUP_START_TIMEOUT, after which it fails like any other start failure. Tests: BoardWorkers.test.ts "a Set up interrupted before the worker answers leaves no stuck clone" and "a worker that never accepts the clone fails Set up instead of hanging". Both fail on the old code.

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 3): fix-upheld. In 7eb3c71, Effect.onExit(abandonClaim) marks the claim failed/worker-offline only while map.get(key) === setup still holds. So the success path (cloning with a workerProjectId) and the typed-failure path (failedSetup) are left alone, and an interruption or defect clears the spinner. The start/retry RPC now has a 30s timeout. Both new BoardWorkers tests hang until timeout on the old code and pass now.

Comment on lines +403 to +412
input.credential.kind === "pairing-token" &&
input.url !== undefined &&
input.url !== worker.url
? yield* locate(worker.url).pipe(
Effect.catchIf(
(error) => error.code === "unreachable",
() => locate(input.url ?? worker.url),
),
)
: yield* locate(input.url ?? worker.url);

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): Re-pair falls back to the client's URL only on unreachable, not when a different server now answers at the recorded address

A worker that moved on a DHCP LAN may leave its old address to another T3 server. locate(worker.url) then succeeds against that server, and the environmentId check that follows fails with different-server ("this link is from "). That is misleading: the pasted token and input.url are correct, and the fallback would have worked. The token isn't spent, because describe runs before the exchange, so the user only sees a confusing error.

Suggested fix: Also fall back to input.url when the recorded URL's descriptor names a different environment. For example, try worker.url, check the environmentId, and on a mismatch or unreachable move to input.url.

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 63dd84c. Re-pair now treats a different environmentId at the recorded address the same as unreachable and moves to the client-supplied URL. If that URL is also a different server, the existing different-server error still applies. Test: BoardWorkers.test.ts "re-pairing from the picker moves when another server now answers at the old address" (fails on the old code).

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 3): fix-upheld. In 63dd84c, filterOrFail on environmentId === worker.id turns a different server at the recorded URL into unreachable, and the existing catchIf then falls back to input.url. The new re-pair test fails on the old code and passes now.

brentkelly and others added 4 commits October 22, 2026 01:30
…orker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and an unstored successor is given back

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one spinning for good

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… answers at the old one

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 4 (re-review at 63dd84c)

This PR lays the distributed-board foundation: the rewritten overview (D19–D22), the worker registry and its event-sourced default, the central server's held connection to each worker with session renewal, the worker-side boardWorker.* RPCs, project ↔ worker mapping with Set up on worker, and the Workers and Project checkouts settings. All four round-3 fixes hold up against the code: renew is gated, failed renewals are retried while the link stays up, a Set up claim can no longer outlive its call, and re-pair moves to the client's address when a different server answers at the old one. The worker test suite passes (7 files, 60 tests). I found nothing blocking this round. I left two nitpicks on edges of the round-3 fixes: a clone the board abandons can keep running on the worker untracked, and the label half of the renew gate is chosen by the client.

Status of round-3 findings

# Prior finding Status
I1 🟡 boardWorker.renew lets any read-scoped session swap itself for a fresh bearer token ✅ closed. Renew refuses a cookie session, a DPoP-bound session and an unlabelled session; see N2 below on the label check
I2 🟡 A failed renewal is never retried while the link stays up ✅ closed. Retries back off from 1 minute to an hourly cap, and a successor that couldn't be stored is released
I3 🟡 Set up's claimed cloning entry is never cleared on interrupt/defect/hang ✅ closed. onExit(abandonClaim) plus a 30-second start timeout; see N1 below
N1 🟢 Re-pair falls back only on unreachable, not on a different server at the recorded address ✅ closed. An environmentId mismatch now counts as unreachable

Comment on lines +726 to +757
Effect.timeoutOrElse({
duration: SETUP_START_TIMEOUT,
orElse: () =>
Effect.fail(new WorkerRpcError({ message: `${worker.label} didn't answer.` })),
}),
Effect.result,
);
if (started._tag === "Failure") {
const cause = started.failure.cause;
const detail =
typeof cause === "object" && cause !== null && "detail" in cause
? String((cause as { readonly detail: unknown }).detail)
: started.failure.message;
const failedSetup: BoardWorkerSetup = {
...setup,
phase: "failed",
endedAt: yield* nowIso,
failure: failed(worker.label, detail),
};
yield* putSetup(failedSetup);
return failedSetup;
}
const workerProjectId = started.success;
const cloning: BoardWorkerSetup = { ...setup, workerProjectId };
yield* putSetup(cloning);
yield* FiberMap.run(
followers,
key,
follow({ key, workerId: worker.id, workerLabel: worker.label, rpc, workerProjectId }),
);
return cloning;
}).pipe(Effect.onExit(() => abandonClaim));

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/2): A Set up abandoned by timeout or client disconnect can leave a clone running on the worker that the board doesn't track

The start runs on the client's request fiber. If the client disconnects, or the 30-second timeout fires after the worker has already accepted startProjectClone, the board records the setup as failed with a null workerProjectId. The clone keeps running on the worker. The next Set up then starts afresh into the same folder and fails with folder-exists, so the user has to recover by hand from a clone that may have succeeded. The window is small, because starting only checks the folder and spawns git, so this is not blocking.

Suggested fix: Run the start-and-follow on a board-owned fiber (the followers FiberMap already exists) so a client going away doesn't cancel it. Or, on abandon, keep the generated projectId in the failed entry so the next Set up can try retryProjectClone or look the clone up instead of colliding with its folder.

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 b4a015f. I took the second option, keeping the id, because it fits how Set up already retries in place. The worker project id is now chosen before the RPC and stays on the setup through any failure: a timeout, an interrupted call, or a refused start. The next Set up for the same folder reads the worker's clone list first. If the clone is still running or already done, Set up follows it again. If the worker has it as failed, Set up retries it in place. If the worker has forgotten it, Set up starts it afresh under the same id. worker-offline failures can now reuse the id too. folder-exists is still excluded, because a retry discards the destination. New test: a Set up abandoned after the worker accepted follows that clone next time. It fails on 63dd84c, where a second clone start went into the same folder, and passes now. I updated a retry the worker no longer tracks… to expect the same id on the fresh start.

Comment on lines +186 to +191
if (
currentSession.method !== "bearer-access-token" ||
currentSession.proofKeyThumbprint !== undefined ||
label === undefined ||
!isBoardSessionLabel(label)
) {

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/2): The board-label half of the renew gate is chosen by the client

isBoardSessionLabel checks client.label, and the client sets that label itself as client_label on /oauth/token (auth/http.ts:390). Any bearer client that names itself T3o board (…) can renew its session forever and get around the 30-day TTL. This can't widen access: scopes are copied, and cookie and DPoP sessions are still refused by the method and thumbprint checks. The label check reads like an authorization gate, though, when it is only a convention.

Suggested fix: Say in the comment that the label is advisory. Or tighten the gate with something the client can't pick freely, such as also requiring the bot device type the board exchanges with. The cleaner option is a server-side marker recorded when the board's session is issued.

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 b4a015f by correcting the comment. It now says the label is advisory: any plain bearer client can name itself that way on /oauth/token and extend its TTL, but only with the subject and scopes it already holds. I didn't add a check on device type because deviceType is also client-presented (auth/utils.ts:194), and a real server-side marker would need a new column on auth sessions, which is an upstream-owned table. That's more than this gap justifies.

…lone back up

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

Copy link
Copy Markdown
Owner Author

Repo change, made alongside this PR: every GitHub Actions workflow in the fork is now disabled.

This PR's auto-merge was held for about 8 hours. Its Mobile Fingerprint Check job asks for a blacksmith-8vcpu-ubuntu-2404 runner, and only upstream has those, so the job stayed queued and never ran. The board counts a pending check as a reason to wait. It retried until it ran out of attempts at 14:15, then held the merge with "Its checks have not finished." #182, #183 and #184 had the same stuck check and were merged by hand.

Workflows from the earliest upstream sync were already disabled. Ones that came in with later syncs were still enabled. These are now disabled in Settings → Actions, so no workflow files changed and upstream syncs won't conflict:

  • Forward to Cursor hygiene
  • Desktop macOS Preview
  • Desktop macOS Preview Publish
  • Issue Labels
  • Mobile Android APK
  • Mobile Fingerprint Check
  • Publish AUR package
  • Release desktop build
  • Web Preview
  • Windows Tests

Two queued runs were cancelled: this PR's fingerprint check (39602449216) and a Mobile Android APK run on t3o (39564744884).

Dependency Graph is still listed as active. It's GitHub's built-in dependency submission, and the API can't disable it. It runs on GitHub's own runners and posts no PR checks, so it can't hold a merge. Turn it off under Settings → Code security if you want it gone.

To turn a workflow back on: gh workflow enable <name>.

@brentkelly
brentkelly merged commit 1da62a5 into board/t3o-60 Oct 21, 2026
1 of 2 checks passed
@brentkelly
brentkelly deleted the board/t3o-62 branch October 21, 2026 21:46
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