@os-eco/overstory-cli 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +33 -8
- package/src/agents/manifest.ts +4 -3
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +420 -1
- package/src/commands/coordinator.ts +173 -1
- package/src/commands/init.test.ts +137 -0
- package/src/commands/init.ts +57 -1
- package/src/commands/inspect.test.ts +398 -1
- package/src/commands/inspect.ts +234 -0
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.ts +312 -322
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/events/tailer.test.ts +461 -0
- package/src/events/tailer.ts +235 -0
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +243 -19
- package/src/merge/resolver.ts +235 -95
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +185 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +61 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.test.ts +30 -0
- package/src/runtimes/sapling.ts +27 -24
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +19 -0
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +123 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
package/src/runtimes/pi.ts
CHANGED
|
@@ -179,10 +179,12 @@ export class PiRuntime implements AgentRuntime {
|
|
|
179
179
|
/**
|
|
180
180
|
* Parse a Pi transcript JSONL file into normalized token usage.
|
|
181
181
|
*
|
|
182
|
-
* Pi JSONL format
|
|
183
|
-
* -
|
|
184
|
-
*
|
|
185
|
-
* -
|
|
182
|
+
* Pi JSONL format (version 3):
|
|
183
|
+
* - Session metadata: `{ type: "session", version: 3, id, cwd }`
|
|
184
|
+
* - Model identity: `{ type: "model_change", provider, modelId }`
|
|
185
|
+
* - Token usage: on `{ type: "message" }` events where `message.role === "assistant"`,
|
|
186
|
+
* nested under `message.usage`: `{ input, output, cacheRead, cacheWrite, totalTokens, cost }`
|
|
187
|
+
* - Cost data: `message.usage.cost.total` (USD)
|
|
186
188
|
*
|
|
187
189
|
* Returns null if the file does not exist or cannot be parsed.
|
|
188
190
|
*
|
|
@@ -212,18 +214,49 @@ export class PiRuntime implements AgentRuntime {
|
|
|
212
214
|
continue;
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
// Model identity from model_change events.
|
|
218
|
+
if (entry.type === "model_change") {
|
|
219
|
+
if (typeof entry.modelId === "string") {
|
|
220
|
+
model = entry.modelId;
|
|
221
|
+
} else if (typeof entry.model === "string") {
|
|
222
|
+
model = entry.model;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Token usage from assistant message events.
|
|
227
|
+
// Pi v3 format: message.usage.input / message.usage.output
|
|
228
|
+
if (entry.type === "message") {
|
|
229
|
+
const msg = entry.message as Record<string, unknown> | undefined;
|
|
230
|
+
if (msg?.role === "assistant") {
|
|
231
|
+
const usage = msg.usage as Record<string, unknown> | undefined;
|
|
232
|
+
if (usage) {
|
|
233
|
+
if (typeof usage.input === "number") {
|
|
234
|
+
inputTokens += usage.input;
|
|
235
|
+
}
|
|
236
|
+
if (typeof usage.output === "number") {
|
|
237
|
+
outputTokens += usage.output;
|
|
238
|
+
}
|
|
239
|
+
// Also count cache tokens toward input for compatibility.
|
|
240
|
+
if (typeof usage.cacheRead === "number") {
|
|
241
|
+
inputTokens += usage.cacheRead;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Capture model from message if model_change was missed.
|
|
246
|
+
if (typeof msg.model === "string" && model === "") {
|
|
247
|
+
model = msg.model;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Fallback: message_end events (older Pi versions).
|
|
215
253
|
if (entry.type === "message_end") {
|
|
216
|
-
// Pi top-level token fields (not nested under message.usage).
|
|
217
254
|
if (typeof entry.inputTokens === "number") {
|
|
218
255
|
inputTokens += entry.inputTokens;
|
|
219
256
|
}
|
|
220
257
|
if (typeof entry.outputTokens === "number") {
|
|
221
258
|
outputTokens += entry.outputTokens;
|
|
222
259
|
}
|
|
223
|
-
} else if (entry.type === "model_change") {
|
|
224
|
-
if (typeof entry.model === "string") {
|
|
225
|
-
model = entry.model;
|
|
226
|
-
}
|
|
227
260
|
}
|
|
228
261
|
}
|
|
229
262
|
|
|
@@ -246,8 +279,24 @@ export class PiRuntime implements AgentRuntime {
|
|
|
246
279
|
return model.env ?? {};
|
|
247
280
|
}
|
|
248
281
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Return the directory containing Pi session transcript files.
|
|
284
|
+
*
|
|
285
|
+
* Pi stores JSONL transcripts in `~/.pi/agent/sessions/{encoded-project-path}/`.
|
|
286
|
+
* The project path is encoded by replacing path separators with `--` and
|
|
287
|
+
* prefixing/suffixing with `--`.
|
|
288
|
+
*
|
|
289
|
+
* Example: `/home/user/project` → `~/.pi/agent/sessions/--home-user-project--/`
|
|
290
|
+
*
|
|
291
|
+
* @param projectRoot - Absolute path to the project root
|
|
292
|
+
* @returns Absolute path to the transcript directory
|
|
293
|
+
*/
|
|
294
|
+
getTranscriptDir(projectRoot: string): string | null {
|
|
295
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
296
|
+
if (!home) return null;
|
|
297
|
+
|
|
298
|
+
// Pi encodes the project path: replace separators with dashes, wrap with --
|
|
299
|
+
const encoded = `--${projectRoot.replace(/[\\/]/g, "-").replace(/:/g, "")}--`;
|
|
300
|
+
return join(home, ".pi", "agent", "sessions", encoded);
|
|
252
301
|
}
|
|
253
302
|
}
|
|
@@ -4,6 +4,7 @@ import { ClaudeRuntime } from "./claude.ts";
|
|
|
4
4
|
import { CodexRuntime } from "./codex.ts";
|
|
5
5
|
import { CopilotRuntime } from "./copilot.ts";
|
|
6
6
|
import { GeminiRuntime } from "./gemini.ts";
|
|
7
|
+
import { OpenCodeRuntime } from "./opencode.ts";
|
|
7
8
|
import { PiRuntime } from "./pi.ts";
|
|
8
9
|
import { getRuntime } from "./registry.ts";
|
|
9
10
|
|
|
@@ -22,7 +23,7 @@ describe("getRuntime", () => {
|
|
|
22
23
|
|
|
23
24
|
it("throws with a helpful message for an unknown runtime", () => {
|
|
24
25
|
expect(() => getRuntime("unknown-runtime")).toThrow(
|
|
25
|
-
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling',
|
|
26
|
+
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling, opencode',
|
|
26
27
|
);
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -118,6 +119,25 @@ describe("getRuntime", () => {
|
|
|
118
119
|
expect(runtime.id).toBe("gemini");
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
it("returns OpenCodeRuntime when name is 'opencode'", () => {
|
|
123
|
+
const runtime = getRuntime("opencode");
|
|
124
|
+
expect(runtime).toBeInstanceOf(OpenCodeRuntime);
|
|
125
|
+
expect(runtime.id).toBe("opencode");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("uses config.runtime.default 'opencode' when name is omitted", () => {
|
|
129
|
+
const config = { runtime: { default: "opencode" } } as OverstoryConfig;
|
|
130
|
+
const runtime = getRuntime(undefined, config);
|
|
131
|
+
expect(runtime).toBeInstanceOf(OpenCodeRuntime);
|
|
132
|
+
expect(runtime.id).toBe("opencode");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("opencode runtime returns a new instance on each call", () => {
|
|
136
|
+
const a = getRuntime("opencode");
|
|
137
|
+
const b = getRuntime("opencode");
|
|
138
|
+
expect(a).not.toBe(b);
|
|
139
|
+
});
|
|
140
|
+
|
|
121
141
|
describe("capability routing", () => {
|
|
122
142
|
it("resolves capability-specific runtime from config", () => {
|
|
123
143
|
const config = {
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { ClaudeRuntime } from "./claude.ts";
|
|
|
6
6
|
import { CodexRuntime } from "./codex.ts";
|
|
7
7
|
import { CopilotRuntime } from "./copilot.ts";
|
|
8
8
|
import { GeminiRuntime } from "./gemini.ts";
|
|
9
|
+
import { OpenCodeRuntime } from "./opencode.ts";
|
|
9
10
|
import { PiRuntime } from "./pi.ts";
|
|
10
11
|
import { SaplingRuntime } from "./sapling.ts";
|
|
11
12
|
import type { AgentRuntime } from "./types.ts";
|
|
@@ -18,6 +19,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
|
|
|
18
19
|
["copilot", () => new CopilotRuntime()],
|
|
19
20
|
["gemini", () => new GeminiRuntime()],
|
|
20
21
|
["sapling", () => new SaplingRuntime()],
|
|
22
|
+
["opencode", () => new OpenCodeRuntime()],
|
|
21
23
|
]);
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -37,6 +39,7 @@ export function getAllRuntimes(): AgentRuntime[] {
|
|
|
37
39
|
new CopilotRuntime(),
|
|
38
40
|
new GeminiRuntime(),
|
|
39
41
|
new SaplingRuntime(),
|
|
42
|
+
new OpenCodeRuntime(),
|
|
40
43
|
];
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -365,6 +365,21 @@ describe("SaplingRuntime", () => {
|
|
|
365
365
|
const argv = runtime.buildDirectSpawn(opts);
|
|
366
366
|
expect(argv[3]).toBe("claude-sonnet-4-6");
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
test("omits --model when model is undefined (sapling uses own config)", () => {
|
|
370
|
+
const opts: DirectSpawnOpts = {
|
|
371
|
+
cwd: "/project/.overstory/worktrees/builder-1",
|
|
372
|
+
env: {},
|
|
373
|
+
instructionPath: "/project/.overstory/worktrees/builder-1/SAPLING.md",
|
|
374
|
+
};
|
|
375
|
+
const argv = runtime.buildDirectSpawn(opts);
|
|
376
|
+
expect(argv).not.toContain("--model");
|
|
377
|
+
expect(argv[0]).toBe("sp");
|
|
378
|
+
expect(argv[1]).toBe("run");
|
|
379
|
+
expect(argv).toContain("--json");
|
|
380
|
+
expect(argv).toContain("--cwd");
|
|
381
|
+
expect(argv).toContain("--system-prompt-file");
|
|
382
|
+
});
|
|
368
383
|
});
|
|
369
384
|
|
|
370
385
|
describe("buildEnv", () => {
|
|
@@ -464,6 +479,21 @@ describe("SaplingRuntime", () => {
|
|
|
464
479
|
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("custom/haiku-gateway-model");
|
|
465
480
|
});
|
|
466
481
|
|
|
482
|
+
test("clears ANTHROPIC_API_KEY by default (no gateway)", () => {
|
|
483
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
484
|
+
const env = runtime.buildEnv(model);
|
|
485
|
+
expect(env.ANTHROPIC_API_KEY).toBe("");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("buildEnv sets ANTHROPIC_API_KEY from gateway provider ANTHROPIC_AUTH_TOKEN", () => {
|
|
489
|
+
const model: ResolvedModel = {
|
|
490
|
+
model: "sonnet",
|
|
491
|
+
env: { ANTHROPIC_AUTH_TOKEN: "sk-gw-test" },
|
|
492
|
+
};
|
|
493
|
+
const env = runtime.buildEnv(model);
|
|
494
|
+
expect(env.ANTHROPIC_API_KEY).toBe("sk-gw-test");
|
|
495
|
+
});
|
|
496
|
+
|
|
467
497
|
test("does NOT forward non-model env vars from model.env", () => {
|
|
468
498
|
const model: ResolvedModel = {
|
|
469
499
|
model: "sonnet",
|
package/src/runtimes/sapling.ts
CHANGED
|
@@ -424,39 +424,39 @@ export class SaplingRuntime implements AgentRuntime {
|
|
|
424
424
|
* @returns Argv array for Bun.spawn — do not shell-interpolate
|
|
425
425
|
*/
|
|
426
426
|
buildDirectSpawn(opts: DirectSpawnOpts): string[] {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
427
|
+
const argv = ["sp", "run"];
|
|
428
|
+
if (opts.model !== undefined) {
|
|
429
|
+
// Resolve the actual model name: if this is an alias (e.g. "sonnet") routed
|
|
430
|
+
// through a gateway, the real model ID is in the env vars. Sapling passes
|
|
431
|
+
// --model directly to the SDK, so it needs the actual model ID, not the alias.
|
|
432
|
+
let model = opts.model;
|
|
433
|
+
let resolved = false;
|
|
434
|
+
if (opts.env) {
|
|
435
|
+
const aliasKey = `ANTHROPIC_DEFAULT_${model.toUpperCase()}_MODEL`;
|
|
436
|
+
const envResolved = opts.env[aliasKey];
|
|
437
|
+
if (envResolved) {
|
|
438
|
+
model = envResolved;
|
|
439
|
+
resolved = true;
|
|
440
|
+
}
|
|
438
441
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
442
|
+
// Fallback: bare aliases (haiku/sonnet/opus) with no gateway env var → concrete model ID.
|
|
443
|
+
if (!resolved) {
|
|
444
|
+
const fallback = SAPLING_ALIAS_FALLBACKS[model];
|
|
445
|
+
if (fallback !== undefined) {
|
|
446
|
+
model = fallback;
|
|
447
|
+
}
|
|
445
448
|
}
|
|
449
|
+
argv.push("--model", model);
|
|
446
450
|
}
|
|
447
|
-
|
|
448
|
-
return [
|
|
449
|
-
"sp",
|
|
450
|
-
"run",
|
|
451
|
-
"--model",
|
|
452
|
-
model,
|
|
451
|
+
argv.push(
|
|
453
452
|
"--json",
|
|
454
453
|
"--cwd",
|
|
455
454
|
opts.cwd,
|
|
456
455
|
"--system-prompt-file",
|
|
457
456
|
opts.instructionPath,
|
|
458
457
|
"Read SAPLING.md for your task assignment and begin immediately.",
|
|
459
|
-
|
|
458
|
+
);
|
|
459
|
+
return argv;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
/**
|
|
@@ -667,6 +667,9 @@ export class SaplingRuntime implements AgentRuntime {
|
|
|
667
667
|
CLAUDECODE: "",
|
|
668
668
|
CLAUDE_CODE_SSE_PORT: "",
|
|
669
669
|
CLAUDE_CODE_ENTRYPOINT: "",
|
|
670
|
+
// Clear ANTHROPIC_API_KEY so the parent session's key doesn't leak
|
|
671
|
+
// into the sapling subprocess. Gateway providers re-set this below.
|
|
672
|
+
ANTHROPIC_API_KEY: "",
|
|
670
673
|
};
|
|
671
674
|
|
|
672
675
|
const providerEnv = model.env ?? {};
|
package/src/runtimes/types.ts
CHANGED
|
@@ -118,8 +118,8 @@ export interface DirectSpawnOpts {
|
|
|
118
118
|
cwd: string;
|
|
119
119
|
/** Environment variables for the subprocess. */
|
|
120
120
|
env: Record<string, string>;
|
|
121
|
-
/** Model ref (alias or provider-qualified). */
|
|
122
|
-
model
|
|
121
|
+
/** Model ref (alias or provider-qualified). When undefined, the runtime omits --model and lets the agent use its own config. */
|
|
122
|
+
model?: string;
|
|
123
123
|
/** Path to the instruction/overlay file for this agent. */
|
|
124
124
|
instructionPath: string;
|
|
125
125
|
}
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ProviderConfig {
|
|
|
20
20
|
export interface ResolvedModel {
|
|
21
21
|
model: string;
|
|
22
22
|
env?: Record<string, string>;
|
|
23
|
+
/** True when the model was explicitly set via config.models[capability]. */
|
|
24
|
+
isExplicitOverride?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** Configuration for the Pi runtime's model alias expansion. */
|
|
@@ -37,6 +39,19 @@ export type TaskTrackerBackend = "auto" | "seeds" | "beads";
|
|
|
37
39
|
|
|
38
40
|
// === Project Configuration ===
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Conditions that trigger automatic coordinator shutdown.
|
|
44
|
+
* All triggers default to false for backward compatibility.
|
|
45
|
+
*/
|
|
46
|
+
export interface CoordinatorExitTriggers {
|
|
47
|
+
/** Exit when all spawned agents have completed and their branches have been merged. */
|
|
48
|
+
allAgentsDone: boolean;
|
|
49
|
+
/** Exit when the task tracker reports no unblocked work (sd/bd ready returns empty). */
|
|
50
|
+
taskTrackerEmpty: boolean;
|
|
51
|
+
/** Exit when a typed shutdown mail is received from an external caller (e.g., greenhouse). */
|
|
52
|
+
onShutdownSignal: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
/** A single quality gate command that agents must pass before reporting completion. */
|
|
41
56
|
export interface QualityGate {
|
|
42
57
|
/** Display name shown in the overlay (e.g., "Tests"). */
|
|
@@ -94,6 +109,10 @@ export interface OverstoryConfig {
|
|
|
94
109
|
verbose: boolean;
|
|
95
110
|
redactSecrets: boolean;
|
|
96
111
|
};
|
|
112
|
+
coordinator?: {
|
|
113
|
+
/** Conditions that trigger automatic coordinator shutdown. */
|
|
114
|
+
exitTriggers: CoordinatorExitTriggers;
|
|
115
|
+
};
|
|
97
116
|
runtime?: {
|
|
98
117
|
/** Default runtime adapter name (default: "claude"). */
|
|
99
118
|
default: string;
|
|
@@ -1977,3 +1977,260 @@ describe("buildCompletionMessage", () => {
|
|
|
1977
1977
|
expect(msg).toContain("3");
|
|
1978
1978
|
});
|
|
1979
1979
|
});
|
|
1980
|
+
|
|
1981
|
+
// === Bug fix tests: headless agent kill blast radius + stale detection ===
|
|
1982
|
+
|
|
1983
|
+
describe("headless agent kill blast radius fix (Bug 1)", () => {
|
|
1984
|
+
/**
|
|
1985
|
+
* Track PID kill calls without spawning real processes.
|
|
1986
|
+
* Also surfaces killTree calls so tests can assert on them.
|
|
1987
|
+
*/
|
|
1988
|
+
function processTracker(): {
|
|
1989
|
+
isAlive: (pid: number) => boolean;
|
|
1990
|
+
killTree: (pid: number) => Promise<void>;
|
|
1991
|
+
killed: number[];
|
|
1992
|
+
} {
|
|
1993
|
+
const killed: number[] = [];
|
|
1994
|
+
return {
|
|
1995
|
+
isAlive: (pid: number) => {
|
|
1996
|
+
try {
|
|
1997
|
+
process.kill(pid, 0);
|
|
1998
|
+
return true;
|
|
1999
|
+
} catch {
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
},
|
|
2003
|
+
killTree: async (pid: number) => {
|
|
2004
|
+
killed.push(pid);
|
|
2005
|
+
},
|
|
2006
|
+
killed,
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
test("headless agent at escalation level 3 kills PID, not tmux session", async () => {
|
|
2011
|
+
const nudgeIntervalMs = 60_000;
|
|
2012
|
+
// stalledSince is 4 intervals ago — expectedLevel = floor(4) = 4, clamped to MAX (3)
|
|
2013
|
+
const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
|
|
2014
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
2015
|
+
|
|
2016
|
+
const session = makeSession({
|
|
2017
|
+
agentName: "headless-stalled",
|
|
2018
|
+
tmuxSession: "", // headless
|
|
2019
|
+
pid: process.pid, // alive PID — ZFC won't trigger direct terminate
|
|
2020
|
+
state: "stalled",
|
|
2021
|
+
lastActivity: staleActivity,
|
|
2022
|
+
escalationLevel: 2,
|
|
2023
|
+
stalledSince,
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
2027
|
+
|
|
2028
|
+
const proc = processTracker();
|
|
2029
|
+
// tmux mock: isSessionAlive("") returns true — simulates prefix-match bug scenario
|
|
2030
|
+
const tmuxMock = tmuxWithLiveness({ "": true });
|
|
2031
|
+
|
|
2032
|
+
await runDaemonTick({
|
|
2033
|
+
root: tempRoot,
|
|
2034
|
+
...THRESHOLDS,
|
|
2035
|
+
nudgeIntervalMs,
|
|
2036
|
+
tier1Enabled: false,
|
|
2037
|
+
_tmux: tmuxMock,
|
|
2038
|
+
_triage: triageAlways("extend"),
|
|
2039
|
+
_process: proc,
|
|
2040
|
+
_eventStore: null,
|
|
2041
|
+
_recordFailure: async () => {},
|
|
2042
|
+
_getConnection: () => undefined,
|
|
2043
|
+
_removeConnection: () => {},
|
|
2044
|
+
_tailerRegistry: new Map(),
|
|
2045
|
+
_findLatestStdoutLog: async () => null,
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
// PID was killed via killTree, NOT via tmux killSession("")
|
|
2049
|
+
expect(proc.killed).toContain(process.pid);
|
|
2050
|
+
expect(tmuxMock.killed).not.toContain("");
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
test("headless agent direct terminate kills PID, not tmux", async () => {
|
|
2054
|
+
// PID 999999 is virtually guaranteed not to exist — health check sees it as dead
|
|
2055
|
+
const deadPid = 999999;
|
|
2056
|
+
const session = makeSession({
|
|
2057
|
+
agentName: "headless-dead-pid",
|
|
2058
|
+
tmuxSession: "", // headless
|
|
2059
|
+
pid: deadPid,
|
|
2060
|
+
state: "working",
|
|
2061
|
+
lastActivity: new Date().toISOString(),
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
2065
|
+
|
|
2066
|
+
const proc = processTracker();
|
|
2067
|
+
// tmux mock: isSessionAlive("") returns true — would kill everything without the fix
|
|
2068
|
+
const tmuxMock = tmuxWithLiveness({ "": true });
|
|
2069
|
+
|
|
2070
|
+
await runDaemonTick({
|
|
2071
|
+
root: tempRoot,
|
|
2072
|
+
...THRESHOLDS,
|
|
2073
|
+
_tmux: tmuxMock,
|
|
2074
|
+
_triage: triageAlways("extend"),
|
|
2075
|
+
_process: proc,
|
|
2076
|
+
_eventStore: null,
|
|
2077
|
+
_recordFailure: async () => {},
|
|
2078
|
+
_getConnection: () => undefined,
|
|
2079
|
+
_removeConnection: () => {},
|
|
2080
|
+
_tailerRegistry: new Map(),
|
|
2081
|
+
_findLatestStdoutLog: async () => null,
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
// Should have attempted PID kill, NOT tmux killSession("")
|
|
2085
|
+
expect(proc.killed).toContain(deadPid);
|
|
2086
|
+
expect(tmuxMock.killed).not.toContain("");
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
test("triage terminate on headless agent kills PID, not tmux", async () => {
|
|
2090
|
+
const nudgeIntervalMs = 60_000;
|
|
2091
|
+
// stalledSince is 2.5 intervals ago — expectedLevel = floor(2.5) = 2 → triage fires
|
|
2092
|
+
const stalledSince = new Date(Date.now() - 2.5 * nudgeIntervalMs).toISOString();
|
|
2093
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
2094
|
+
|
|
2095
|
+
const session = makeSession({
|
|
2096
|
+
agentName: "headless-triage-terminate",
|
|
2097
|
+
tmuxSession: "", // headless
|
|
2098
|
+
pid: process.pid, // alive
|
|
2099
|
+
state: "stalled",
|
|
2100
|
+
lastActivity: staleActivity,
|
|
2101
|
+
escalationLevel: 1,
|
|
2102
|
+
stalledSince,
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
2106
|
+
|
|
2107
|
+
const proc = processTracker();
|
|
2108
|
+
const tmuxMock = tmuxWithLiveness({ "": true });
|
|
2109
|
+
|
|
2110
|
+
await runDaemonTick({
|
|
2111
|
+
root: tempRoot,
|
|
2112
|
+
...THRESHOLDS,
|
|
2113
|
+
nudgeIntervalMs,
|
|
2114
|
+
tier1Enabled: true,
|
|
2115
|
+
_tmux: tmuxMock,
|
|
2116
|
+
_triage: triageAlways("terminate"), // AI triage says terminate
|
|
2117
|
+
_nudge: nudgeTracker().nudge,
|
|
2118
|
+
_process: proc,
|
|
2119
|
+
_eventStore: null,
|
|
2120
|
+
_recordFailure: async () => {},
|
|
2121
|
+
_getConnection: () => undefined,
|
|
2122
|
+
_removeConnection: () => {},
|
|
2123
|
+
_tailerRegistry: new Map(),
|
|
2124
|
+
_findLatestStdoutLog: async () => null,
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
// Should have killed the PID, not the tmux session
|
|
2128
|
+
expect(proc.killed).toContain(process.pid);
|
|
2129
|
+
expect(tmuxMock.killed).not.toContain("");
|
|
2130
|
+
});
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
describe("headless agent stale detection via events.db (Bug 2)", () => {
|
|
2134
|
+
test("headless agent with recent events in events.db is not flagged stale", async () => {
|
|
2135
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
2136
|
+
|
|
2137
|
+
const session = makeSession({
|
|
2138
|
+
agentName: "headless-active",
|
|
2139
|
+
tmuxSession: "", // headless
|
|
2140
|
+
pid: process.pid, // alive
|
|
2141
|
+
state: "working",
|
|
2142
|
+
lastActivity: staleActivity, // stale — would trigger escalate without event fallback
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
2146
|
+
|
|
2147
|
+
const eventsDbPath = join(tempRoot, ".overstory", "events.db");
|
|
2148
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
2149
|
+
|
|
2150
|
+
try {
|
|
2151
|
+
// Insert a recent event for this agent (within the stale threshold window)
|
|
2152
|
+
eventStore.insert({
|
|
2153
|
+
runId: null,
|
|
2154
|
+
agentName: "headless-active",
|
|
2155
|
+
sessionId: null,
|
|
2156
|
+
eventType: "tool_end",
|
|
2157
|
+
toolName: "Read",
|
|
2158
|
+
toolArgs: null,
|
|
2159
|
+
toolDurationMs: 100,
|
|
2160
|
+
level: "info",
|
|
2161
|
+
data: null,
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
const checks: HealthCheck[] = [];
|
|
2165
|
+
|
|
2166
|
+
await runDaemonTick({
|
|
2167
|
+
root: tempRoot,
|
|
2168
|
+
...THRESHOLDS,
|
|
2169
|
+
onHealthCheck: (c) => checks.push(c),
|
|
2170
|
+
_tmux: tmuxAllAlive(),
|
|
2171
|
+
_triage: triageAlways("extend"),
|
|
2172
|
+
_process: { isAlive: () => true, killTree: async () => {} },
|
|
2173
|
+
_eventStore: eventStore,
|
|
2174
|
+
_recordFailure: async () => {},
|
|
2175
|
+
_getConnection: () => undefined,
|
|
2176
|
+
_removeConnection: () => {},
|
|
2177
|
+
_tailerRegistry: new Map(),
|
|
2178
|
+
_findLatestStdoutLog: async () => null,
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// Recent events found — lastActivity was refreshed, agent is NOT stalled
|
|
2182
|
+
expect(checks).toHaveLength(1);
|
|
2183
|
+
expect(checks[0]?.action).toBe("none");
|
|
2184
|
+
expect(checks[0]?.state).toBe("working");
|
|
2185
|
+
|
|
2186
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
2187
|
+
expect(reloaded[0]?.state).toBe("working");
|
|
2188
|
+
} finally {
|
|
2189
|
+
eventStore.close();
|
|
2190
|
+
}
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
test("headless agent with no recent events IS flagged stale", async () => {
|
|
2194
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
2195
|
+
|
|
2196
|
+
const session = makeSession({
|
|
2197
|
+
agentName: "headless-silent",
|
|
2198
|
+
tmuxSession: "", // headless
|
|
2199
|
+
pid: process.pid, // alive
|
|
2200
|
+
state: "working",
|
|
2201
|
+
lastActivity: staleActivity, // stale
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
2205
|
+
|
|
2206
|
+
const eventsDbPath = join(tempRoot, ".overstory", "events.db");
|
|
2207
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
2208
|
+
|
|
2209
|
+
try {
|
|
2210
|
+
// No events inserted for this agent — event fallback finds nothing
|
|
2211
|
+
|
|
2212
|
+
const checks: HealthCheck[] = [];
|
|
2213
|
+
|
|
2214
|
+
await runDaemonTick({
|
|
2215
|
+
root: tempRoot,
|
|
2216
|
+
...THRESHOLDS,
|
|
2217
|
+
onHealthCheck: (c) => checks.push(c),
|
|
2218
|
+
_tmux: tmuxAllAlive(),
|
|
2219
|
+
_triage: triageAlways("extend"),
|
|
2220
|
+
_process: { isAlive: () => true, killTree: async () => {} },
|
|
2221
|
+
_eventStore: eventStore,
|
|
2222
|
+
_recordFailure: async () => {},
|
|
2223
|
+
_getConnection: () => undefined,
|
|
2224
|
+
_removeConnection: () => {},
|
|
2225
|
+
_tailerRegistry: new Map(),
|
|
2226
|
+
_findLatestStdoutLog: async () => null,
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
// No recent events — lastActivity stays stale, agent IS flagged stalled
|
|
2230
|
+
expect(checks).toHaveLength(1);
|
|
2231
|
+
expect(checks[0]?.action).toBe("escalate");
|
|
2232
|
+
} finally {
|
|
2233
|
+
eventStore.close();
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
});
|