Skip to content

Commit d37560c

Browse files
committed
refactor(server): extract IdleReaper; one owner for the close deadline
Quality audit of my own feature. Four structural problems, all self-inflicted. 1. The idle sweeper was inlined into SessionManager: +204 lines onto an already-1472-line file, plus 4 constructor options (idleCloseMs, idleSweepMs, setTimer, clearTimer), 6 private fields and its own re-entrancy flag. It is a periodic policy collaborator, which this codebase already has a shape for — MetricsCollector and the ports service both own their window, clock and injected timer and are wired in server.ts. Now `idle_reaper.ts` does the same, reaching the session world through a two-function seam. manager.ts drops back to +59 over base, ManagerOpts loses 4 options, and serve.ts returns to untouched (the env parsing lives with the module that owns the policy). The tests got the bigger win: the reaper reads a handful of session fields, so they now use plain object doubles instead of standing up a SessionManager, SQLite store and temp dir per case. Each case states exactly the session shape it is about and nothing else can affect the outcome. 2. Two nested close deadlines with one caller between them. The adapter bounded its own `session/close` AND the manager bounded `adapter.close()` — which is how the previous commit shipped them inverted, and why it needed an invariant test to hold them in order. Defence in depth against nobody: `closeSession` is the only caller. Deleted the adapter-level deadline, the constant, the option, the invariant test, and `bestEffort()` (a wrapper with one call site once the redundancy was gone). Adapters now issue the plain request and MAY reject or hang; the manager bounds and swallows it in one place, for every back end, and reaps either way. The contract is stated on `AgentAdapter.close` instead of half-implemented in three adapters. 3. `deadline.ts` was created as the canonical home for bounded agent waits and then acp.ts's three pre-existing ad-hoc timeouts were left in place — four ways to do one thing. Converged onto `withDeadline`, deleting the local `withTimeout` closure and two raw `Promise.race` blocks; acp.ts shrinks despite gaining the close path. 4. A triple-negative status check (`!== "running" && !== "awaiting-input" && !== "awaiting-approval"`) was the only place asking "is this agent mid-flight?" — a missing model, not a formatting problem. Now `isBusy(status)` next to the SessionStatus type it interrogates, and `Session.cold` replaces two `instanceof DetachedAdapter` checks. Also caught by the type checker mid-refactor: I had added a second `allSessions()` returning Iterable when a canonical one returning Session[] already existed, which broke its existing callers. Removed; the reaper reuses the canonical accessor. No behaviour change. 1297 server tests green, and the stub e2e still proves close -> not active -> in the Closed list -> a message transparently reopens.
1 parent b9c7461 commit d37560c

14 files changed

Lines changed: 452 additions & 573 deletions

server/src/adapters/acp.test.ts

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,10 +1312,12 @@ test("close() is a no-op when the agent does not advertise session/close", async
13121312
});
13131313

13141314
/**
1315-
* A close that rejects (agent already wedged) must never block teardown — the
1316-
* caller still has to reap the process, or we are back to leaking RSS.
1315+
* The adapter makes the plain request and lets a failure surface;
1316+
* `SessionManager.closeSession` owns bounding and swallowing it (and reaps
1317+
* regardless). Asserted so the contract cannot quietly drift back to each
1318+
* adapter half-handling teardown policy on its own.
13171319
*/
1318-
test("close() swallows a failing session/close so teardown can proceed", async () => {
1320+
test("close() propagates a failing session/close for the manager to absorb", async () => {
13191321
const { transport } = pair((conn) => {
13201322
const a = new ScriptedAgent(conn, async () => {});
13211323
(a as unknown as { initialize: () => Promise<unknown> }).initialize = async () => ({
@@ -1331,7 +1333,7 @@ test("close() swallows a failing session/close so teardown can proceed", async (
13311333
const adapter = new AcpAdapter({ spec: { agent: "codex", command: "x" }, connect: () => transport });
13321334
await adapter.start({ cwd: "/tmp" });
13331335

1334-
await adapter.close(); // must not throw
1336+
await assert.rejects(() => adapter.close());
13351337
});
13361338

13371339
test("start({resumeAgentSessionId}) loads and drops the replayed history (silent mode, SPEC-29)", async () => {
@@ -1573,33 +1575,3 @@ test("start() leaves the agent's model alone when it already matches, or is unkn
15731575
await a2.kill();
15741576
});
15751577

1576-
/**
1577-
* A wedged agent is the single most important case for close-then-reap: it is
1578-
* exactly the agent that needs killing. If `session/close` never answers,
1579-
* `close()` must still settle, or `manager.closeSession` never reaches `kill()`
1580-
* — the RSS is never reclaimed and the idle sweeper stays wedged forever.
1581-
*/
1582-
test("close() gives up on a session/close that never answers", async () => {
1583-
const { transport } = pair((conn) => {
1584-
const a = new ScriptedAgent(conn, async () => {});
1585-
(a as unknown as { initialize: () => Promise<unknown> }).initialize = async () => ({
1586-
protocolVersion: 1 as const,
1587-
agentCapabilities: { sessionCapabilities: { close: {} } },
1588-
});
1589-
// Never resolves — a hung agent, not a rejecting one.
1590-
(a as unknown as { closeSession: () => Promise<unknown> }).closeSession = () =>
1591-
new Promise(() => {});
1592-
return a;
1593-
});
1594-
1595-
const adapter = new AcpAdapter({
1596-
spec: { agent: "codex", command: "x" },
1597-
connect: () => transport,
1598-
closeTimeoutMs: 30,
1599-
});
1600-
await adapter.start({ cwd: "/tmp" });
1601-
1602-
const started = Date.now();
1603-
await adapter.close(); // must settle, not hang
1604-
assert.ok(Date.now() - started < 2000, "close() must not block on a hung agent");
1605-
});

server/src/adapters/acp.ts

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { sharedMediaStore, type MediaStore } from "../media/store.js";
3737
import { LocalMediaResolver, rewriteMarkdownImages } from "../media/local.js";
3838
import { prepareTurnOrFail } from "../media/attach.js";
3939
import { spawnLineProcess } from "./child_transport.js";
40-
import { bestEffort } from "./deadline.js";
4140
import { onPath } from "./catalog.js";
4241
import { mapElicitation, type ElicitationParams } from "./interaction.js";
4342
import { isRecord } from "./wire.js";
@@ -51,16 +50,6 @@ import { log } from "../log.js";
5150
*/
5251
const ACP_HANDSHAKE_TIMEOUT = 15_000;
5352

54-
/**
55-
* Deadline for the graceful `session/close` (SPEC-29). Deliberately its own
56-
* constant rather than reusing {@link ACP_HANDSHAKE_TIMEOUT}: closing is a
57-
* teardown courtesy, not a launch step, and it MUST stay strictly below the
58-
* manager's `DEFAULT_CLOSE_GRACE_MS` backstop. If it were the looser of the two
59-
* it could never fire on the normal `closeSession` path — the manager would
60-
* abandon the call first and `kill()` would dispose the transport out from under
61-
* a still-pending timer. Asserted by a test in `manager.test.ts`.
62-
*/
63-
export const ACP_CLOSE_TIMEOUT = 8_000;
6453

6554
export interface AcpSpawnSpec {
6655
/** makit agent label surfaced in the session DTO ("pi", "codex", …). */
@@ -91,12 +80,6 @@ export interface AcpAdapterOpts {
9180
* spawning a subprocess. Production leaves this unset → real subprocess.
9281
*/
9382
connect?: (cwd: string, env: Record<string, string>) => AcpTransport;
94-
/**
95-
* Deadline for the graceful `session/close` (default
96-
* {@link ACP_CLOSE_TIMEOUT}). A hung agent must not be able to stall teardown
97-
* — see {@link AcpAdapter.close}. Tests shorten it.
98-
*/
99-
closeTimeoutMs?: number;
10083
/**
10184
* Blob store for assistant display media (SPEC-22). Defaults to the shared
10285
* `~/.makit/media` store the `/media` route serves from; tests inject a
@@ -109,8 +92,6 @@ export class AcpAdapter extends SubprocessAdapter {
10992
readonly agent: string;
11093

11194
private readonly spec: AcpSpawnSpec;
112-
/** Deadline for the graceful `session/close` (see {@link close}). */
113-
private readonly closeTimeoutMs: number;
11495
private readonly connectFn: (cwd: string, env: Record<string, string>) => AcpTransport;
11596
/**
11697
* True when this adapter spawns {@link AcpSpawnSpec.command} for real, so
@@ -157,7 +138,6 @@ export class AcpAdapter extends SubprocessAdapter {
157138
this.agent = opts.spec.agent;
158139
this.connectFn = opts.connect ?? defaultConnect(opts.spec);
159140
this.spawnsRealBinary = opts.connect === undefined;
160-
this.closeTimeoutMs = opts.closeTimeoutMs ?? ACP_CLOSE_TIMEOUT;
161141
const media = (this.media = opts.media ?? sharedMediaStore());
162142
this.mapper = new AcpEventMapper({
163143
emit: (e) => this.emit("event", e),
@@ -520,25 +500,16 @@ export class AcpAdapter extends SubprocessAdapter {
520500
}
521501

522502
/**
523-
* Graceful ACP `session/close`: cancels any in-flight turn agent-side and
524-
* frees the session's resources, leaving it listable/resumable. Best-effort
525-
* by contract — neither a rejection NOR a hang here may stop {@link kill} from
526-
* reclaiming the process, which is what actually returns the memory.
527-
*
528-
* The deadline is the load-bearing part: a wedged agent is exactly the one
529-
* that needs killing, and `conn.closeSession()` against it never settles. An
530-
* unbounded await would strand teardown before `kill()` and leave the caller's
531-
* sweep permanently in-flight. Mirrors codex's bounded `thread/unsubscribe`.
503+
* ACP `session/close`: cancels any in-flight turn agent-side and frees the
504+
* session's resources, leaving it listable/resumable. Plain request — a hung
505+
* or rejecting agent is `SessionManager.closeSession`'s problem, and it bounds
506+
* this call for every back end rather than each adapter re-implementing (and
507+
* having to keep in step with) the same deadline.
532508
*/
533509
async close(): Promise<void> {
534510
if (!this.conn || !this.acpSessionId) return;
535511
if (!this.capabilities.close || !this.conn.closeSession) return;
536-
await bestEffort(
537-
() => this.conn!.closeSession({ sessionId: this.acpSessionId! }).then(() => undefined),
538-
this.closeTimeoutMs,
539-
"ACP session/close",
540-
(why) => log.warn(`[makit] ACP session/close gave up: ${why}`),
541-
);
512+
await this.conn.closeSession({ sessionId: this.acpSessionId });
542513
}
543514

544515
async kill(): Promise<void> {

server/src/adapters/adapter.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,16 @@ export interface AgentAdapter extends EventEmitter {
171171
sendAction?(action: string, args?: Record<string, unknown>): Promise<void>;
172172
cancel(): Promise<void>;
173173
/**
174-
* Gracefully release the agent-side session before the process is reaped
175-
* (ACP `session/close`, codex `thread/unsubscribe`). Per ACP this implies a
176-
* cancel of any in-flight turn, then frees the session's resources while
177-
* leaving it listable and resumable.
174+
* Ask the agent to release this session before its process is reaped (ACP
175+
* `session/close`, codex `thread/unsubscribe`). Per ACP this implies a cancel
176+
* of any in-flight turn, then frees the session's resources while leaving it
177+
* listable and resumable. A back end without the capability no-ops.
178178
*
179-
* MUST NOT throw: it is the courtesy half of a teardown whose second half
180-
* ({@link kill}) is what actually reclaims memory, so a wedged agent must not
181-
* be able to block the reap. A back end without the capability no-ops.
179+
* MAY reject and MAY hang: this is the courtesy half of a teardown whose
180+
* second half ({@link kill}) is what actually reclaims memory.
181+
* `SessionManager.closeSession` is the single owner of that policy — it bounds
182+
* this call and swallows the outcome, then reaps regardless — so adapters
183+
* implement the plain request and nothing more.
182184
*/
183185
close(): Promise<void>;
184186
kill(signal?: NodeJS.Signals): Promise<void>;

server/src/adapters/codex.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -869,13 +869,13 @@ test("close() before start is a no-op (no thread to unsubscribe)", async () => {
869869
});
870870

871871
/**
872-
* A wedged codex must not be able to block the reap — otherwise the RSS this
873-
* whole path exists to reclaim stays held.
872+
* As with ACP: the adapter just issues `thread/unsubscribe`. Bounding and
873+
* swallowing belong to `SessionManager.closeSession`, which reaps either way.
874874
*/
875-
test("close() swallows a failing thread/unsubscribe", async () => {
875+
test("close() propagates a failing thread/unsubscribe for the manager to absorb", async () => {
876876
const fake = fakeAppServer({ unsubscribe: () => ({ error: { code: -32603, message: "boom" } }) });
877877
const adapter = new CodexAppServerAdapter({ connect: () => fake.transport });
878878
await adapter.start({ cwd: "/tmp" });
879879

880-
await adapter.close(); // must not throw
880+
await assert.rejects(() => adapter.close());
881881
});

server/src/adapters/codex.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -365,15 +365,9 @@ export class CodexAppServerAdapter extends SubprocessAdapter {
365365
*/
366366
async close(): Promise<void> {
367367
if (!this.transport || !this.threadId) return;
368-
try {
369-
await withDeadline(
370-
this.request("thread/unsubscribe", { threadId: this.threadId }),
371-
CODEX_HANDSHAKE_TIMEOUT,
372-
"codex thread/unsubscribe",
373-
);
374-
} catch (e) {
375-
log.warn(`[makit] codex thread/unsubscribe failed: ${(e as Error).message}`);
376-
}
368+
// Plain request: `request()` already carries its own per-call timeout, and
369+
// `SessionManager.closeSession` owns the bound-and-swallow policy.
370+
await this.request("thread/unsubscribe", { threadId: this.threadId });
377371
}
378372

379373
async kill(): Promise<void> {

server/src/adapters/deadline.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
2-
* Deadline helpers shared by the adapters and the manager.
2+
* Bounded waits on an agent, shared by the adapters and the manager.
33
*
4-
* Extracted from `codex.ts` when `AgentAdapter.close()` landed: a graceful
5-
* agent-side release is only ever best-effort, so every wait on an agent must be
6-
* bounded. An unbounded one turns "release then reap" into "hang forever and
7-
* never reap" — precisely the RSS leak the close path exists to fix, in the one
8-
* case that matters most (a wedged agent is the one that needs killing).
4+
* Extracted from `codex.ts` when `AgentAdapter.close()` landed: every wait on an
5+
* agent must be bounded. An unbounded one turns "release then reap" into "hang
6+
* forever and never reap" — precisely the RSS leak the close path exists to fix,
7+
* in the one case that matters most, since a wedged agent is the one that needs
8+
* killing.
99
*/
1010

1111
/** Thrown when a bounded wait on an agent does not settle in time. */
@@ -24,23 +24,3 @@ export function withDeadline<T>(p: Promise<T>, ms: number, label: string): Promi
2424
});
2525
return Promise.race([p, deadline]).finally(() => clearTimeout(timer));
2626
}
27-
28-
/**
29-
* Await [p], but never for longer than [ms] and never throwing: on timeout or
30-
* rejection the reason is handed to [onGaveUp] and the call resolves anyway.
31-
*
32-
* For teardown steps that are courtesies rather than requirements — the caller
33-
* has a hard fallback (killing the process) that MUST still run.
34-
*/
35-
export async function bestEffort(
36-
p: () => Promise<void>,
37-
ms: number,
38-
label: string,
39-
onGaveUp: (why: string) => void,
40-
): Promise<void> {
41-
try {
42-
await withDeadline(p(), ms, label);
43-
} catch (e) {
44-
onGaveUp(e instanceof Error ? e.message : String(e));
45-
}
46-
}

0 commit comments

Comments
 (0)