Repository navigation
feat(board): distributed board foundation, worker registry and connection - #185
Conversation
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
left a comment
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const start: Effect.Effect<unknown, WorkerRpcError> = retryInPlace | ||
| ? rpc.retryProjectClone(workerProjectId) | ||
| : rpc.startProjectClone({ | ||
| projectId: workerProjectId, | ||
| title: project.title, | ||
| createdAt: startedAt, | ||
| destinationPath, | ||
| remoteUrl, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)].
There was a problem hiding this comment.
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.
| 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; | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const probed = yield* pair({ | ||
| workerId: worker.id, | ||
| httpBaseUrl: target.httpBaseUrl, | ||
| token: target.token, | ||
| workerLabel: worker.label, | ||
| workerVersion: descriptor.serverVersion, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const toWsUrl = (url: string, ticket: string): string => { | ||
| const next = new URL("/ws", url); | ||
| next.protocol = next.protocol === "https:" ? "wss:" : "ws:"; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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 |
| 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 }; | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 9a61336 (+ 273d5b9). The board now renews its own session instead of expiring:
boardWorker.helloreports the calling session'ssessionExpiresAt.- New
boardWorker.renewissues 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. WorkerConnectionsholds an online link until it drops or untilSESSION_RENEW_AHEAD(7 days) before expiry. At that point it renews, stores the successor,releases the old session, and redials without leavingonline(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.
There was a problem hiding this comment.
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.
| return; | ||
| } | ||
| await submit({ | ||
| credential: { kind: "pairing-token", token: minted.value.token }, | ||
| url: minted.value.httpBaseUrl, | ||
| expectedEnvironmentId: environment.environmentId, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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").
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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))); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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 | |
| 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). |
| [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."))), | ||
| ), |
There was a problem hiding this comment.
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
renewand 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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.resultcaptures typed failures only. - The worker call never returns.
callinWorkerTransport.tshas 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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 |
| 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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if ( | ||
| currentSession.method !== "bearer-access-token" || | ||
| currentSession.proofKeyThumbprint !== undefined || | ||
| label === undefined || | ||
| !isBoardSessionLabel(label) | ||
| ) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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 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:
Two queued runs were cancelled: this PR's fingerprint check (39602449216) and a Mobile Android APK run on 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: |
… 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>
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