Skip to content

Commit 757656a

Browse files
authored
Merge pull request #1263 from cluesmith/builder/bugfix-1261
[Bugfix #1261] Hold Tower API requests until boot wiring completes
2 parents fc82e94 + b775f02 commit 757656a

9 files changed

Lines changed: 471 additions & 32 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
id: bugfix-1261
2+
title: tower-serves-api-requests-befo
3+
protocol: bugfix
4+
phase: pr
5+
plan_phases: []
6+
current_plan_phase: null
7+
gates:
8+
pr:
9+
status: approved
10+
requested_at: '2026-07-26T21:23:32.875Z'
11+
approved_at: '2026-07-27T05:25:11.442Z'
12+
iteration: 1
13+
build_complete: false
14+
history: []
15+
started_at: '2026-07-26T21:08:00.200Z'
16+
updated_at: '2026-07-27T05:25:11.443Z'
17+
pr_ready_for_human: false

codev/resources/arch.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,22 @@ Tower binds its port and starts serving immediately, but `reconcileTerminalSessi
240240

241241
**Invariant for new Tower-startup work**: any endpoint that reads `workspaceTerminals` to build a response should route through `getRehydratedTerminalsEntry` so it inherits the gate, rather than reading the map directly.
242242

243+
#### Boot Readiness Gate (#1261)
244+
245+
The #997 barrier gates one dependency (reconcile's output) for the readers that name it. Every *other* boot dependency was still unguarded, and the largest of them was `initInstances()` — which sets `tower-instances.ts`'s `_deps` and was the last step of the boot sequence. Requests landing before it got whatever a half-wired Tower could produce: `DELETE /api/terminals/:id` returned **404 for a terminal that existed**, because `killTerminalWithShellper()` returns a bare `false` when `_deps` is null and the route reads that as "not found". The window scaled with disk state — the #1227 husk sweep and #1238 log sweep ran inside it — so a log-heavy machine failed deterministically while CI stayed green.
246+
247+
The fix inverts the default from "serve whatever we have" to "serve nothing until wired":
248+
249+
- The port still binds first. It is the single-Tower mutex (a second `afx tower start` needs `EADDRINUSE`) and what every readiness probe connects to.
250+
- `tower-server.ts` holds each request in `http.createServer` until `bootSequence()` calls `markBootComplete()`; held requests get 503 + `Retry-After` if boot exceeds `BOOT_READY_TIMEOUT_MS` (20s), so a hung boot fails loud instead of hanging clients forever.
251+
- `markBootComplete()` fires as soon as the *dependencies* are wired (through `initCron()`). Maintenance and background services — husk sweep, log-retention sweep, `initTunnel()` — deliberately run **after** it. Gating readiness on `initTunnel()` in particular would make an unreachable cloud endpoint look like a broken local Tower.
252+
253+
This also makes `afx tower start`'s readiness signal honest: it polls `/api/status` for a 200, which the pre-fix Tower returned during the window (`getInstances()` returns `[]` when `_deps` is null).
254+
255+
**Invariant for new Tower-startup work**: anything a request handler depends on must be wired *before* `markBootComplete()`; anything else (sweeps, timers, remote connections) goes after it. Do not add work between `server.listen()` and the gate.
256+
257+
**Second line of defence**: `instancesReady()` lets routes distinguish "not wired yet" from "no such thing" — the `_deps`-dependent terminal-delete paths answer 503 rather than 404 or a lying 204. Unreachable while the gate holds, and deliberately so: the failure mode if it is ever bypassed should be honest.
258+
243259
#### Wire Protocol
244260

245261
Binary frame format: `[1-byte type] [4-byte big-endian length] [payload]`
@@ -1713,20 +1729,23 @@ The startup ordering is critical — race conditions have caused real bugs when
17131729

17141730
| Step | Operation | Why this order |
17151731
|------|-----------|----------------|
1716-
| 1 | HTTP server binds to `localhost:port` | Must be listening before anything registers routes |
1732+
| 1 | HTTP server binds to `localhost:port` | Single-Tower mutex + what readiness probes connect to. **Requests are held, not served, until step 9** (#1261) |
17171733
| 2 | SessionManager init + stale socket cleanup | Prepares shellper infrastructure |
17181734
| 3 | `initTerminals()` | Terminal management module ready |
17191735
| 4 | `startSendBuffer()` | Typing-aware message delivery ready |
17201736
| 5 | **`reconcileTerminalSessions()`** | **MUST run before step 7** — reconnects shellper sessions from previous run |
17211737
| 6 | `killOrphanedShellpers()` | **MUST run after step 5** — avoids killing sessions that were just reconnected |
17221738
| 7 | `initInstances()` | Enables workspace API handlers — triggers dashboard polling |
17231739
| 8 | `initCron()` | Scheduler starts after instances ready |
1724-
| 9 | `initTunnel()` | Cloud tunnel connects last |
1725-
| 10 | WebSocket upgrade handler installed | Terminal connections accepted |
1740+
| 9 | **`markBootComplete()`** | **Readiness gate opens** — held requests are released and Tower starts serving (#1261) |
1741+
| 10 | Husk sweep (#1227) + session-log sweep (#1238) | Maintenance: scales with disk state, so it must not gate the API |
1742+
| 11 | `initTunnel()` | Cloud tunnel connects last — a remote endpoint must never gate local readiness |
1743+
| 12 | WebSocket upgrade handler installed | Terminal connections accepted (installed at module load; not gated — see #997 barrier) |
17261744

17271745
**Known ordering bugs**:
17281746
- **Bugfix #274**: `initInstances()` before `reconcileTerminalSessions()` allowed dashboard polls to race with reconciliation, corrupting shellper sessions
17291747
- **Bugfix #341**: Killing orphaned shellpers before reconciliation killed sessions that were about to be reconnected
1748+
- **Bugfix #1261**: `initInstances()` ran last, *after* the two disk-scaling sweeps, so every `_deps`-dependent route was broken for as long as those scans took — `DELETE /api/terminals/:id` 404'd for a terminal that existed. Fixed by moving the sweeps after readiness and holding requests until step 9
17301749

17311750
**Defense in depth**: During startup, `getTerminalsForWorkspace()` skips on-the-fly shellper reconnection (via `_reconciling` guard) to prevent races through alternate code paths.
17321751

codev/state/bugfix-1261_thread.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# bugfix-1261 — Tower serves API before internal wiring completes
2+
3+
## Investigate
4+
5+
Issue: `DELETE /api/terminals/:id` 404s for an existing terminal during Tower's
6+
startup window, because the whole boot sequence lives inside the
7+
`server.listen()` callback and `initInstances()` (which sets `_deps`) is the
8+
*last* async step. `killTerminalWithShellper()` bails with `false` when
9+
`_deps` is null; the DELETE route maps that to 404.
10+
11+
Confirmed by reading:
12+
- `tower-server.ts:375``server.listen(...)` callback holds the entire boot.
13+
- `tower-server.ts:575``initInstances()` is last, after stale-socket cleanup,
14+
consolidation, `reconcileTerminalSessions()`, `killOrphanedShellpers()`,
15+
the #1227 husk sweep, and the #1238 session-log retention scan.
16+
- `tower-instances.ts:800``killTerminalWithShellper``if (!_deps) return false`.
17+
- `tower-routes.ts:842` — DELETE translates that `false` into 404 NOT_FOUND.
18+
19+
Extra finding not in the issue: `afx tower start` treats a 200 from
20+
`/api/status` as readiness (`commands/tower.ts:110`), and `/api/status`
21+
answers 200 during the window (`getInstances()` returns `[]` when `_deps` is
22+
null). So the CLI's "Tower started" is a *false* readiness signal — that is
23+
what makes `afx tower start && <immediate afx cmd>` racy.
24+
25+
### Reproduced
26+
27+
Wrote a scratchpad repro that tight-loop-connects to the port and fires
28+
create+DELETE the instant it binds (the e2e helper's 200ms poll adds slack that
29+
usually hides it). On this machine, with an *empty* log dir:
30+
31+
```
32+
POST /api/terminals -> 201 (269ms after port open)
33+
DELETE /api/terminals/:id -> 404 {"error":"NOT_FOUND", ...}
34+
GET /api/terminals/:id -> 200 <-- terminal exists; the 404 was spurious
35+
```
36+
37+
Deterministic, 100% of runs. So the window is not exotic — it just needs a
38+
client that doesn't sleep before its first request.
39+
40+
Planned fix (three parts, all small):
41+
1. Readiness gate in `tower-server.ts`: `server.listen()` still binds first
42+
(preserves EADDRINUSE detection + the single-Tower mutex), but
43+
`handleRequest` awaits a boot-complete promise before dispatching, with a
44+
bounded timeout → 503 + `Retry-After`.
45+
2. Reorder boot: move `initInstances()` up to right after the ordering-
46+
constrained steps; push the disk-scaling husk sweep + log retention sweep
47+
after it. Shrinks the window from O(disk) to O(process scan).
48+
3. Defense in depth: DELETE (and any `_deps`-dependent route) returns 503
49+
"Tower is starting up" instead of 404, generalizing what `stopInstance`
50+
already does.
51+
52+
## Fix
53+
54+
All three parts implemented.
55+
56+
- `tower-server.ts`: readiness gate (`bootComplete` + `whenBootComplete`) in
57+
the `http.createServer` handler; the listen callback now just logs and kicks
58+
off a named `bootSequence()`. Boot-throw still exits the process (was an
59+
unhandled rejection before, same net effect). Held requests get 503 +
60+
`Retry-After` if boot exceeds 20s.
61+
- `tower-server.ts`: `initInstances()` + `initCron()` moved up to right after
62+
`killOrphanedShellpers()`; the #1227 husk sweep, the #1238 log-retention
63+
sweep, and `initTunnel()` now run *after* the gate opens. Tunnel especially:
64+
gating local readiness on a remote connect would make an unreachable cloud
65+
endpoint look like a broken Tower. Measured effect on this machine: boot
66+
reaches ready at ~90ms instead of running the disk scans first.
67+
- `tower-instances.ts`: new `instancesReady()`; `tower-routes.ts`: DELETE
68+
`/api/terminals/:id` and the workspace tab-delete path return 503 instead of
69+
404 / a lying 204 when the module isn't wired.
70+
- Test hook `AF_TEST_BOOT_DELAY_MS` widens the window deterministically —
71+
without it the race depends on this machine's process table and log volume,
72+
which is exactly why CI never saw it.
73+
74+
### Verification
75+
76+
Scratchpad repro, same invocation as before the fix:
77+
78+
```
79+
POST /api/terminals -> 201
80+
DELETE /api/terminals/:id -> 204 (was 404)
81+
GET /api/terminals/:id -> 404 (was 200 — i.e. it really is gone now)
82+
```
83+
84+
New `tower-startup-readiness.e2e.test.ts` (2 tests) passes. Confirmed it
85+
*fails* with the gate disabled in dist: DELETE 503 (the second-line guard
86+
firing) and the status-hold assertion 2ms vs the required ≥1200ms.
87+
88+
Known adjacent case left alone: WebSocket upgrades bypass the gate
89+
(`setupUpgradeHandler` attaches its own listener). Not reachable in practice —
90+
every client does an HTTP call first — so out of BUGFIX scope; noted in the PR.
91+
92+
## PR
93+
94+
PR #1263 — "[Bugfix #1261] Hold Tower API requests until boot wiring completes".
95+
96+
CMAP (3-way, `--issue 1261 --project-id bugfix-1261`; note the bare
97+
`consult --protocol bugfix --type pr` form bails with "Multiple projects
98+
found" in this repo — it needs the issue/project flags):
99+
100+
| Model | Verdict | Confidence | Key issues |
101+
|--------|---------|------------|------------|
102+
| gemini | APPROVE | HIGH | None |
103+
| codex | APPROVE | HIGH | None |
104+
| claude | APPROVE | HIGH | None |
105+
106+
No REQUEST_CHANGES, so nothing to address or rebut. Claude raised two
107+
non-blocking observations and dismissed both itself: the `waitForPortImmediate`
108+
name, and whether the `elapsed >= BOOT_DELAY_MS * 0.8` assertion is CI-timing
109+
sensitive (it isn't — a slow machine only makes `>=` more true).
110+
111+
Awaiting the human `pr` gate.

packages/codev/src/agent-farm/__tests__/helpers/tower-test-utils.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,23 @@ export async function waitForPort(port: number, timeoutMs: number): Promise<bool
8080
return false;
8181
}
8282

83+
/**
84+
* Wait for a port to start listening, retrying as tightly as the event loop
85+
* allows.
86+
*
87+
* Issue #1261: waitForPort's 200ms cadence usually lands well after Tower's
88+
* boot sequence finishes, which is why the startup-window race stayed
89+
* invisible to the test suite. Tests that need to hit the instant of the bind
90+
* — before anything else has had a chance to run — use this instead.
91+
*/
92+
export async function waitForPortImmediate(port: number, timeoutMs: number): Promise<boolean> {
93+
const start = Date.now();
94+
while (Date.now() - start < timeoutMs) {
95+
if (await isPortListening(port)) return true;
96+
}
97+
return false;
98+
}
99+
83100
/**
84101
* Find an available port starting from the given port
85102
*/
@@ -91,11 +108,23 @@ async function findAvailablePort(startPort: number): Promise<number> {
91108
throw new Error(`No available port found starting from ${startPort}`);
92109
}
93110

111+
export interface StartTowerOptions {
112+
/**
113+
* Return the moment the port accepts a connection rather than on the next
114+
* 200ms poll tick. Issue #1261 tests need to race the bind itself.
115+
*/
116+
returnAtBind?: boolean;
117+
}
118+
94119
/**
95120
* Start the tower server for testing.
96121
* Creates an isolated socket directory for shellper sessions (Spec 0116).
97122
*/
98-
export async function startTower(port?: number, extraEnv?: Record<string, string>): Promise<TowerHandle> {
123+
export async function startTower(
124+
port?: number,
125+
extraEnv?: Record<string, string>,
126+
opts?: StartTowerOptions,
127+
): Promise<TowerHandle> {
99128
const actualPort = port ?? (await findAvailablePort(14100));
100129

101130
// Spec 0116: Create isolated socket dir so tests don't pollute ~/.codev/run/
@@ -120,7 +149,9 @@ export async function startTower(port?: number, extraEnv?: Record<string, string
120149
proc.stderr?.on('data', (d) => (stderr += d.toString()));
121150

122151
// Wait for tower to start
123-
const started = await waitForPort(actualPort, TOWER_START_TIMEOUT);
152+
const started = opts?.returnAtBind
153+
? await waitForPortImmediate(actualPort, TOWER_START_TIMEOUT)
154+
: await waitForPort(actualPort, TOWER_START_TIMEOUT);
124155
if (!started) {
125156
proc.kill();
126157
try { rmSync(socketDir, { recursive: true, force: true }); } catch { /* ignore */ }

packages/codev/src/agent-farm/__tests__/tower-routes.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ vi.mock('../servers/tower-instances.js', () => ({
6363
getDirectorySuggestions: vi.fn(async () => []),
6464
launchInstance: vi.fn(async () => ({ success: true })),
6565
killTerminalWithShellper: vi.fn(async () => true),
66+
// Issue #1261: routes that need the instances module ask this first, so a
67+
// wired-up Tower is the default for every route test here.
68+
instancesReady: vi.fn(() => true),
6669
stopInstance: vi.fn(async () => ({ ok: true })),
6770
addArchitect: vi.fn(async () => ({ success: true, name: 'sibling', terminalId: 'term-arch-sibling' })),
6871
removeArchitect: vi.fn(async () => ({ success: true })),
@@ -978,6 +981,24 @@ describe('tower-routes', () => {
978981
expect(deleteTerminalSession).not.toHaveBeenCalled();
979982
expect(removeTerminalFromRegistry).not.toHaveBeenCalled();
980983
});
984+
985+
// Issue #1261: "Tower isn't wired up yet" is not "no such terminal".
986+
// Answering 404 sent callers off hunting for a terminal that was there all
987+
// along; 503 + Retry-After tells them to try again instead.
988+
it('returns 503 rather than 404 when the instances module is not wired yet', async () => {
989+
const { instancesReady, killTerminalWithShellper } = await import('../servers/tower-instances.js');
990+
(instancesReady as any).mockReturnValueOnce(false);
991+
992+
const req = makeReq('DELETE', `/api/terminals/${terminalId}`);
993+
const { res, statusCode, body, headers } = makeRes();
994+
await handleRequest(req, res, makeCtx());
995+
996+
expect(statusCode()).toBe(503);
997+
expect(headers()['Retry-After']).toBe('1');
998+
expect(JSON.parse(body()).error).toBe('STARTING_UP');
999+
// And it must not have tried to kill anything on the way out.
1000+
expect(killTerminalWithShellper).not.toHaveBeenCalled();
1001+
});
9811002
});
9821003

9831004
// =========================================================================
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Issue #1261: Tower must not serve API requests before its internal
3+
* dependency wiring is complete.
4+
*
5+
* Tower binds its port first — the port is the single-Tower mutex and the
6+
* thing every readiness probe checks — but binding used to mean "serving".
7+
* Requests landing between the bind and `initInstances()` were answered by a
8+
* half-wired Tower: `DELETE /api/terminals/:id` returned 404 for a terminal
9+
* that existed, because `killTerminalWithShellper()` bails when `_deps` is
10+
* null and the route reads that `false` as "not found".
11+
*
12+
* These tests widen that window to a known duration with
13+
* AF_TEST_BOOT_DELAY_MS, so the race is deterministic rather than a function
14+
* of how long the boot's disk work takes on the machine running the suite.
15+
* That is why the bug hid from CI: fresh runners boot fast enough that the
16+
* window closed before the test's first request arrived.
17+
*/
18+
19+
import { describe, it, expect, afterEach } from 'vitest';
20+
import {
21+
startTower,
22+
cleanupAllTerminals,
23+
cleanupTestDb,
24+
type TowerHandle,
25+
} from './helpers/tower-test-utils.js';
26+
27+
const TEST_TOWER_PORT = 14107;
28+
29+
// Long enough that no plausible scheduling delay lets a request slip in after
30+
// wiring completes; short enough to keep the suite quick.
31+
const BOOT_DELAY_MS = 1500;
32+
33+
let tower: TowerHandle | null = null;
34+
35+
afterEach(async () => {
36+
if (tower) {
37+
await cleanupAllTerminals(tower.port);
38+
await tower.stop();
39+
cleanupTestDb(tower.port);
40+
tower = null;
41+
}
42+
});
43+
44+
describe('Tower startup readiness (Issue #1261)', () => {
45+
it('does not answer DELETE /api/terminals/:id with a spurious 404 during startup', async () => {
46+
tower = await startTower(
47+
TEST_TOWER_PORT,
48+
{ AF_TEST_BOOT_DELAY_MS: String(BOOT_DELAY_MS) },
49+
{ returnAtBind: true },
50+
);
51+
52+
// Both requests are issued inside the delayed window. Before the fix the
53+
// DELETE returned 404 while the terminal was demonstrably still there.
54+
const createRes = await fetch(`http://localhost:${TEST_TOWER_PORT}/api/terminals`, {
55+
method: 'POST',
56+
headers: { 'Content-Type': 'application/json' },
57+
body: JSON.stringify({ label: 'readiness-1261' }),
58+
});
59+
expect(createRes.status).toBe(201);
60+
const created = await createRes.json();
61+
62+
const deleteRes = await fetch(
63+
`http://localhost:${TEST_TOWER_PORT}/api/terminals/${created.id}`,
64+
{ method: 'DELETE' },
65+
);
66+
expect(deleteRes.status).toBe(204);
67+
68+
// And the delete really happened — a 204 that killed nothing would be a
69+
// different lie with the same status code.
70+
const getRes = await fetch(`http://localhost:${TEST_TOWER_PORT}/api/terminals/${created.id}`);
71+
expect(getRes.status).toBe(404);
72+
}, 30_000);
73+
74+
it('holds requests issued at bind time until wiring completes', async () => {
75+
tower = await startTower(
76+
TEST_TOWER_PORT,
77+
{ AF_TEST_BOOT_DELAY_MS: String(BOOT_DELAY_MS) },
78+
{ returnAtBind: true },
79+
);
80+
81+
// `afx tower start` polls /api/status and treats 200 as "Tower is up".
82+
// That signal was dishonest: /api/status answered 200 during the window
83+
// because getInstances() returns [] when the module isn't wired. The gate
84+
// makes the first 200 mean what the CLI assumes it means.
85+
const started = Date.now();
86+
const statusRes = await fetch(`http://localhost:${TEST_TOWER_PORT}/api/status`);
87+
const elapsed = Date.now() - started;
88+
89+
expect(statusRes.status).toBe(200);
90+
expect(elapsed).toBeGreaterThanOrEqual(BOOT_DELAY_MS * 0.8);
91+
}, 30_000);
92+
});

packages/codev/src/agent-farm/servers/tower-instances.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ export function shutdownInstances(): void {
168168
_deps = null;
169169
}
170170

171+
/**
172+
* Whether the module has been wired up.
173+
*
174+
* Issue #1261: routes that call into this module need to tell "not wired yet"
175+
* apart from "no such thing", because those deserve different answers — 503
176+
* "try again" versus 404 "it isn't here". `killTerminalWithShellper()` returns
177+
* a bare boolean and cannot express the difference, so the DELETE route lands
178+
* on 404 for a terminal that exists. Tower's readiness gate now holds requests
179+
* until boot completes, so this should never be false at request time; it is
180+
* kept as a second line of defence.
181+
*/
182+
export function instancesReady(): boolean {
183+
return _deps !== null;
184+
}
185+
171186
// ============================================================================
172187
// Known workspace registration
173188
// ============================================================================

0 commit comments

Comments
 (0)