History

Fix liveness probe, --json output, and remaining unsettled state

Follow-up review of the pairing path and a second pass over seeding: - `isPersistedServerRuntimeStateLive` reported non-positive pids as live. `process.kill` reads 0 and -1 as signal-group targets rather than process lookups, so neither throws and both were caught by the "it didn't throw, so it's running" branch. A truncated or hand-edited file recording either would be believed forever — the dev candidate wins the devUrl preference, so it also outranks a genuinely live server elsewhere and mints tokens into a database nothing reads. Rejected in the schema and re-checked at the call site; the errno cast is now a guard, since an out-of-range pid throws a TypeError with no `code`. - `pairing url --json` emitted warnings on stdout ahead of the payload. The probe reads both state directories, so a corrupt file in the one *not* in use broke JSON for any consumer piping it; the quiet-logs layer was only applied around the issuing step that runs after. Verified with a crafted fixture: stdout is now valid JSON. - Turns copied in the `pending` state were left unsettled. Same defect as the `running` case fixed previously — a user message accepted before its provider turn started, which only that thread's next message would ever clear. - The source was read across a dozen implicit transactions while the installed app may be writing to it, so a project could vanish between the query that selected it and the copy that read its row. One deferred read transaction pins every query to a single snapshot. - `--from` and `--to` resolving to the same directory is now refused. Not for the data loss it appears to be (the source reads a pre-transaction snapshot, so the rows come back — verified in both journal modes), but because the event log, the cursors, and everything outside the caps do not.

Theo Browne committed 3 months ago

Commit 8a76bb81 parent 373e7918 files · +167 −26Browse files

Unified · Split

8 files
  1. apps/server/src/cli/auth.ts +10 −1
  2. apps/server/src/serverRuntimeState.test.ts +24 −0
  3. apps/server/src/serverRuntimeState.ts +21 −2
  4. scripts/dev-seed.ts +23 −2
  5. scripts/lib/dev-seed.test.ts +12 −7
  6. scripts/lib/dev-seed.ts +48 −11
  7. scripts/lib/server-runtime-probe.test.ts +14 −0
  8. scripts/lib/server-runtime-probe.ts +15 −3
apps/server/src/cli/auth.ts+10−1
@@ -210,7 +210,16 @@ const pairingUrlCommand = Command.make("url", { · 209 unmodified lines
210 ? flags210 ? flags
211 : { ...flags, baseDir: Option.some(worktreeBaseDir) };211 : { ...flags, baseDir: Option.some(worktreeBaseDir) };
212 const config = yield* resolveCliAuthConfig(resolvedFlags, logLevel);212 const config = yield* resolveCliAuthConfig(resolvedFlags, logLevel);
213 const live = yield* findLiveServerRuntimeState(config);213 // Quieted here rather than only around the issuing layer below: probing
214 // reads both state directories, and a corrupt file in the one *not* in
215 // use logs a Warn to stdout — ahead of the payload, breaking `--json`
216 // for any consumer piping it. The warning is about a file the command
217 // then ignores, so under --json it is noise either way.
218 const live = yield* findLiveServerRuntimeState(config).pipe(
219 Effect.provide(
220 Layer.succeed(References.MinimumLogLevel, flags.json ? "Error" : config.logLevel),
221 ),
222 );
214 223
215 if (Option.isNone(live)) {224 if (Option.isNone(live)) {
216 return yield* new NoRunningServerError({ baseDir: config.baseDir });225 return yield* new NoRunningServerError({ baseDir: config.baseDir });
apps/server/src/serverRuntimeState.test.ts+24−0
@@ -70,6 +70,30 @@ describe("serverRuntimeState", () => { · 69 unmodified lines
70 );70 );
71 }),71 }),
72 );72 );
73
74 // `process.kill` reads non-positive pids as signal-group targets rather
75 // than process lookups — 0 is the caller's own group, -1 every permitted
76 // process — so neither throws, and a probe that only catches would call
77 // both "live" no matter what wrote the file.
78 for (const pid of [0, -1]) {
79 it.effect(`reports pid ${String(pid)} as dead rather than signalling a group`, () =>
80 Effect.gen(function* () {
81 assert.isFalse(
82 yield* ServerRuntimeState.isPersistedServerRuntimeStateLive(stateForPid(pid)),
83 );
84 }),
85 );
86 }
87
88 // Out of range for a pid: process.kill throws a TypeError with no errno,
89 // which the EPERM check must not assume the shape of.
90 it.effect("reports an out-of-range pid as dead", () =>
91 Effect.gen(function* () {
92 assert.isFalse(
93 yield* ServerRuntimeState.isPersistedServerRuntimeStateLive(stateForPid(2 ** 40)),
94 );
95 }),
96 );
73 });97 });
74 98
75 it.effect("treats a missing runtime state file as absent", () =>99 it.effect("treats a missing runtime state file as absent", () =>
apps/server/src/serverRuntimeState.ts+21−2
@@ -10,7 +10,11 @@ import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; · 9 unmodified lines
10 10
11export const PersistedServerRuntimeState = Schema.Struct({11export const PersistedServerRuntimeState = Schema.Struct({
12 version: Schema.Literal(1),12 version: Schema.Literal(1),
13 pid: Schema.Int,13 /**
14 * Positive: `process.kill` reads 0 and negatives as signal-group targets, so
15 * a liveness probe on one reports "live" for a process that never existed.
16 */
17 pid: Schema.Int.check(Schema.isGreaterThan(0)),
14 host: Schema.optional(Schema.String),18 host: Schema.optional(Schema.String),
15 port: Schema.Int,19 port: Schema.Int,
16 origin: Schema.String,20 origin: Schema.String,
@@ -107,6 +111,13 @@ export const clearPersistedServerRuntimeState = (path: string) => · 90 unmodified lines
107 );111 );
108 });112 });
109 113
114/**
115 * `process.kill` rejects an out-of-range pid with a TypeError rather than an
116 * errno, so the catch below cannot assume either shape.
117 */
118const isErrnoException = (cause: unknown): cause is NodeJS.ErrnoException =>
119 typeof cause === "object" && cause !== null && "code" in cause;
120
110/**121/**
111 * Whether the process that wrote a runtime-state file is still alive.122 * Whether the process that wrote a runtime-state file is still alive.
112 *123 *
@@ -123,12 +134,20 @@ export const isPersistedServerRuntimeStateLive = ( · 10 unmodified lines
123 state: PersistedServerRuntimeState,134 state: PersistedServerRuntimeState,
124): Effect.Effect<boolean> =>135): Effect.Effect<boolean> =>
125 Effect.sync(() => {136 Effect.sync(() => {
137 // `process.kill` reads non-positive pids as signal-group targets rather
138 // than process lookups: 0 signals the caller's own group and -1 every
139 // permitted process, so neither throws and both would report "live"
140 // unconditionally. A server never records one; a corrupt or truncated file
141 // can, and that file would then be believed forever.
142 if (!Number.isSafeInteger(state.pid) || state.pid <= 0) {
143 return false;
144 }
126 try {145 try {
127 process.kill(state.pid, 0);146 process.kill(state.pid, 0);
128 return true;147 return true;
129 } catch (cause) {148 } catch (cause) {
130 // EPERM means the process exists but belongs to another user.149 // EPERM means the process exists but belongs to another user.
131 return (cause as NodeJS.ErrnoException).code === "EPERM";150 return isErrnoException(cause) && cause.code === "EPERM";
132 }151 }
133 });152 });
134 153
scripts/dev-seed.ts+23−2
@@ -28,7 +28,13 @@ const MAX_THREAD_LIMIT = 32_766; · 27 unmodified lines
28class DevSeedTargetError extends Schema.TaggedErrorClass<DevSeedTargetError>()(28class DevSeedTargetError extends Schema.TaggedErrorClass<DevSeedTargetError>()(
29 "DevSeedTargetError",29 "DevSeedTargetError",
30 {30 {
31 reason: Schema.Literals(["shared-home", "missing-target", "not-a-worktree", "server-running"]),31 reason: Schema.Literals([
32 "shared-home",
33 "missing-target",
34 "not-a-worktree",
35 "server-running",
36 "same-source",
37 ]),
32 detail: Schema.String,38 detail: Schema.String,
33 },39 },
34) {40) {
@@ -56,6 +62,13 @@ class DevSeedTargetError extends Schema.TaggedErrorClass<DevSeedTargetError>()( · 21 unmodified lines
56 "which leaves the running server's in-memory read model describing rows that no longer exist.",62 "which leaves the running server's in-memory read model describing rows that no longer exist.",
57 "Stop it, run the seed, then start it again.",63 "Stop it, run the seed, then start it again.",
58 ].join("\n");64 ].join("\n");
65 case "same-source":
66 return [
67 `Refusing to seed ${this.detail} from itself.`,
68 "The copy would restore what the delete removed, but the event log and the",
69 "projector cursors are cleared either way — so the directory loses its history",
70 "and keeps only whatever the thread and activity caps selected.",
71 ].join("\n");
59 }72 }
60 }73 }
61}74}
@@ -167,13 +180,21 @@ const devSeedCli = Command.make("dev-seed", { · 105 unmodified lines
167 180
168 // The whole point is to keep dev data off the real home; overwriting it181 // The whole point is to keep dev data off the real home; overwriting it
169 // here would be the exact accident this command exists to avoid.182 // here would be the exact accident this command exists to avoid.
170 const [canonicalTarget, canonicalShared] = yield* Effect.all([183 const [canonicalTarget, canonicalSource, canonicalShared] = yield* Effect.all([
171 fileSystem.realPath(targetBaseDir).pipe(Effect.orElseSucceed(() => targetBaseDir)),184 fileSystem.realPath(targetBaseDir).pipe(Effect.orElseSucceed(() => targetBaseDir)),
185 fileSystem.realPath(sourceBaseDir).pipe(Effect.orElseSucceed(() => sourceBaseDir)),
172 fileSystem.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),186 fileSystem.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
173 ]);187 ]);
174 if (canonicalTarget === canonicalShared) {188 if (canonicalTarget === canonicalShared) {
175 return yield* new DevSeedTargetError({ reason: "shared-home", detail: targetBaseDir });189 return yield* new DevSeedTargetError({ reason: "shared-home", detail: targetBaseDir });
176 }190 }
191 // Seeding a directory from itself is not the data loss it looks like —
192 // the source reads a pre-transaction snapshot, so the copy puts the rows
193 // back. What does not come back is the event log and the cursors, and
194 // anything outside the caps. Nobody means to ask for that.
195 if (canonicalTarget === canonicalSource) {
196 return yield* new DevSeedTargetError({ reason: "same-source", detail: targetBaseDir });
197 }
177 198
178 const targetDbPath = stateDbPath(path, targetBaseDir);199 const targetDbPath = stateDbPath(path, targetBaseDir);
179 if (!(yield* fileSystem.exists(targetDbPath))) {200 if (!(yield* fileSystem.exists(targetDbPath))) {
scripts/lib/dev-seed.test.ts+12−7
@@ -101,8 +101,10 @@ function makeSource( · 100 unmodified lines
101 }101 }
102 // The newest thread is left mid-turn, as a copy taken while an agent is102 // The newest thread is left mid-turn, as a copy taken while an agent is
103 // working would be: a running turn with no completion, and a message still103 // working would be: a running turn with no completion, and a message still
104 // streaming into it.104 // streaming into it. The one before it is left "pending" — a user message
105 // accepted but whose provider turn never started.
105 const midFlight = index === threadCount - 1;106 const midFlight = index === threadCount - 1;
107 const queued = index === threadCount - 2;
106 database108 database
107 .prepare(109 .prepare(
108 `INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, completed_at, checkpoint_files_json)110 `INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, completed_at, checkpoint_files_json)
@@ -111,9 +113,9 @@ function makeSource( · 2 unmodified lines
111 .run(113 .run(
112 threadId,114 threadId,
113 `turn-${threadId}`,115 `turn-${threadId}`,
114 midFlight ? "running" : "completed",116 midFlight ? "running" : queued ? "pending" : "completed",
115 at,117 at,
116 midFlight ? null : at,118 midFlight || queued ? null : at,
117 );119 );
118 database120 database
119 .prepare(`INSERT INTO projection_thread_sessions VALUES (?,'running','claude',?,'boom',?)`)121 .prepare(`INSERT INTO projection_thread_sessions VALUES (?,'running','claude',?,'boom',?)`)
@@ -297,10 +299,11 @@ describe("seedDevDatabase", () => { · 177 unmodified lines
297 });299 });
298 });300 });
299 301
300 // A copied running turn has no agent left to finish it. The session override302 // A copied turn has no agent left to finish it, whether it was already
301 // alone does not cover this: the turn is read independently, and an unsettled303 // running or still queued behind a "pending" row. The session override alone
302 // one keeps the thread spinning and its timeline unfoldable forever.304 // does not cover either: the turn is read independently, and an unsettled one
303 it("settles turns copied mid-flight", () => {305 // keeps the thread spinning and its timeline unfoldable forever.
306 it("settles turns copied before they finished", () => {
304 withDatabases(({ source, target }) => {307 withDatabases(({ source, target }) => {
305 seedDevDatabase({308 seedDevDatabase({
306 sourceDbPath: source,309 sourceDbPath: source,
@@ -314,9 +317,11 @@ describe("seedDevDatabase", () => { · 7 unmodified lines
314 target,317 target,
315 "SELECT state, completed_at FROM projection_turns",318 "SELECT state, completed_at FROM projection_turns",
316 );319 );
320 // The fixture puts one running turn and one pending turn in this range.
317 assert.isAbove(turns.length, 0);321 assert.isAbove(turns.length, 0);
318 for (const turn of turns) {322 for (const turn of turns) {
319 assert.notEqual(turn.state, "running");323 assert.notEqual(turn.state, "running");
324 assert.notEqual(turn.state, "pending");
320 assert.isNotNull(turn.completed_at);325 assert.isNotNull(turn.completed_at);
321 }326 }
322 });327 });
scripts/lib/dev-seed.ts+48−11
@@ -216,6 +216,16 @@ function copyRows(input: { · 215 unmodified lines
216 return { copied, skipped };216 return { copied, skipped };
217}217}
218 218
219/**
220 * Turn states that describe work an agent still owes: "running" for a turn in
221 * flight, "pending" for one whose user message was accepted but whose provider
222 * turn had not started yet (`insertPendingProjectionTurn` in
223 * apps/server/src/persistence/Layers/ProjectionTurns.ts). Neither can make
224 * progress in a copy, and only the thread's own next message would clear a
225 * pending one.
226 */
227const UNSETTLED_TURN_STATES = ["running", "pending"] as const;
228
219/**229/**
220 * Settles turns copied mid-flight, matching what the server does to a turn230 * Settles turns copied mid-flight, matching what the server does to a turn
221 * whose session leaves "running": `settledTurnStateForSessionStatus` in231 * whose session leaves "running": `settledTurnStateForSessionStatus` in
@@ -223,26 +233,28 @@ function copyRows(input: { · 1 unmodified line
223 * status the sessions are copied with to "interrupted".233 * status the sessions are copied with to "interrupted".
224 *234 *
225 * An UPDATE rather than a copy override, because only the rows that were235 * An UPDATE rather than a copy override, because only the rows that were
226 * actually running should change; the rest keep their real recorded state.236 * actually unsettled should change; the rest keep their real recorded state.
227 */237 */
228function settleRunningTurns(238function settleUnfinishedTurns(
229 target: NodeSqlite.DatabaseSync,239 target: NodeSqlite.DatabaseSync,
230 threadIds: ReadonlyArray<string>,240 threadIds: ReadonlyArray<string>,
231): void {241): void {
232 if (threadIds.length === 0 || !hasColumn(target, "projection_turns", "state")) {242 if (threadIds.length === 0 || !hasColumn(target, "projection_turns", "state")) {
233 return;243 return;
234 }244 }
235 // completed_at is what marks a turn settled alongside its state; a running245 // completed_at is what marks a turn settled alongside its state; an
236 // turn has none, and leaving it null keeps the thread unsettled regardless.246 // unfinished turn has none, and leaving it null keeps the thread unsettled
247 // regardless of the state written here.
237 const setCompletedAt = hasColumn(target, "projection_turns", "completed_at")248 const setCompletedAt = hasColumn(target, "projection_turns", "completed_at")
238 ? `, completed_at = COALESCE(completed_at, requested_at)`249 ? `, completed_at = COALESCE(completed_at, requested_at)`
239 : "";250 : "";
240 target251 target
241 .prepare(252 .prepare(
242 `UPDATE projection_turns SET state = 'interrupted'${setCompletedAt}253 `UPDATE projection_turns SET state = 'interrupted'${setCompletedAt}
243 WHERE state = 'running' AND thread_id IN (${placeholders(threadIds.length)})`,254 WHERE state IN (${placeholders(UNSETTLED_TURN_STATES.length)})
255 AND thread_id IN (${placeholders(threadIds.length)})`,
244 )256 )
245 .run(...threadIds);257 .run(...UNSETTLED_TURN_STATES, ...threadIds);
246}258}
247 259
248export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary {260export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary {
@@ -267,6 +279,22 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { · 18 unmodified lines
267 );279 );
268 }280 }
269 281
282 // The source is normally the *live* installed app's database — only the
283 // target is guarded against a running server, because reading is harmless.
284 // Reading it across a dozen separate implicit transactions is not: the app
285 // can delete a project between the query that picks it up and the copy that
286 // reads its row, leaving a copied thread whose project no longer exists (no
287 // foreign keys are declared, so nothing catches it). One deferred read
288 // transaction pins every query below to a single snapshot.
289 let sourceTransactionOpen = false;
290 try {
291 source.exec("BEGIN");
292 sourceTransactionOpen = true;
293 } catch {
294 // A concurrent writer can leave the connection unable to start one. The
295 // copy is still worth doing — it just loses snapshot isolation.
296 }
297
270 try {298 try {
271 // Threads the user actually touched most recently. Mirrors the sidebar's own299 // Threads the user actually touched most recently. Mirrors the sidebar's own
272 // ordering (packages/client-runtime/src/state/threadSort.ts). Both recency300 // ordering (packages/client-runtime/src/state/threadSort.ts). Both recency
@@ -376,11 +404,11 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { · 103 unmodified lines
376 }),404 }),
377 );405 );
378 // A thread copied mid-turn has no agent to finish it. The session status is406 // A thread copied mid-turn has no agent to finish it. The session status is
379 // forced to "stopped" below, but the turn is read independently: a407 // forced to "stopped" below, but the turn is read independently: an
380 // `state = 'running'` turn keeps the thread unsettled and unfoldable408 // unfinished turn keeps the thread unsettled and unfoldable forever
381 // forever (deriveUnsettledTurnId in MessagesTimeline.logic.ts). Interrupted409 // (deriveUnsettledTurnId in MessagesTimeline.logic.ts). Interrupted is what
382 // is what the server itself settles an abandoned turn to.410 // the server itself settles an abandoned turn to.
383 settleRunningTurns(target, threadIds);411 settleUnfinishedTurns(target, threadIds);
384 const messages = record(412 const messages = record(
385 copyRows({413 copyRows({
386 source,414 source,
@@ -475,6 +503,15 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { · 88 unmodified lines
475 ? cause503 ? cause
476 : new DevSeedError(`could not seed the dev database: ${String(cause)}`);504 : new DevSeedError(`could not seed the dev database: ${String(cause)}`);
477 } finally {505 } finally {
506 if (sourceTransactionOpen) {
507 try {
508 // Read-only, so there is nothing to commit; end it before closing so
509 // the snapshot is released explicitly rather than by teardown.
510 source.exec("ROLLBACK");
511 } catch {
512 // Already ended, which is equally fine for a read-only transaction.
513 }
514 }
478 source.close();515 source.close();
479 target.close();516 target.close();
480 }517 }
scripts/lib/server-runtime-probe.test.ts+14−0
@@ -86,6 +86,20 @@ describe("findRunningServerPid", () => { · 85 unmodified lines
86 }).pipe(Effect.provide(NodeServices.layer)),86 }).pipe(Effect.provide(NodeServices.layer)),
87 );87 );
88 88
89 // `process.kill` reads these as signal-group targets, not process lookups, so
90 // neither throws — a naive probe calls both "live" and blocks seeding forever
91 // on a file no server ever wrote.
92 for (const pid of [0, -1]) {
93 it.effect(`ignores a state file recording pid ${String(pid)}`, () =>
94 Effect.gen(function* () {
95 const baseDir = yield* makeBaseDir();
96 yield* writeRuntimeState({ baseDir, stateDir: "dev", contents: runtimeStateJson(pid) });
97
98 assert.isTrue(Option.isNone(yield* findRunningServerPid(baseDir)));
99 }).pipe(Effect.provide(NodeServices.layer)),
100 );
101 }
102
89 it.effect("reports nothing when no state files exist", () =>103 it.effect("reports nothing when no state files exist", () =>
90 Effect.gen(function* () {104 Effect.gen(function* () {
91 const baseDir = yield* makeBaseDir();105 const baseDir = yield* makeBaseDir();
scripts/lib/server-runtime-probe.ts+15−3
@@ -16,7 +16,8 @@ import * as Schema from "effect/Schema"; · 15 unmodified lines
16const STATE_DIRS = ["dev", "userdata"] as const;16const STATE_DIRS = ["dev", "userdata"] as const;
17 17
18const RuntimeStateProbe = Schema.Struct({18const RuntimeStateProbe = Schema.Struct({
19 pid: Schema.Int,19 /** Positive, for the reason `isPidLive` explains. */
20 pid: Schema.Int.check(Schema.isGreaterThan(0)),
20});21});
21 22
22const decodeRuntimeStateProbe = Schema.decodeUnknownEffect(23const decodeRuntimeStateProbe = Schema.decodeUnknownEffect(
@@ -34,12 +35,23 @@ const decodeRuntimeStateProbe = Schema.decodeUnknownEffect( · 11 unmodified lines
34 * case is being told to stop a server that is already stopped.35 * case is being told to stop a server that is already stopped.
35 */36 */
36const isPidLive = (pid: number): boolean => {37const isPidLive = (pid: number): boolean => {
38 // `process.kill` reads non-positive pids as signal-group targets rather than
39 // process lookups — 0 signals the caller's own group, -1 every permitted
40 // process — so neither throws and both would report "live" unconditionally.
41 // The schema rejects those, and this repeats the check so the invariant
42 // travels with the code that depends on it.
43 if (!Number.isSafeInteger(pid) || pid <= 0) {
44 return false;
45 }
37 try {46 try {
38 process.kill(pid, 0);47 process.kill(pid, 0);
39 return true;48 return true;
40 } catch (cause) {49 } catch (cause) {
41 // EPERM means the process exists but belongs to another user.50 // EPERM means the process exists but belongs to another user. An
42 return (cause as NodeJS.ErrnoException).code === "EPERM";51 // out-of-range pid throws a TypeError instead, which has no errno.
52 return typeof cause === "object" && cause !== null && "code" in cause
53 ? cause.code === "EPERM"
54 : false;
43 }55 }
44};56};
45 57