@os-eco/overstory-cli 0.7.9 → 0.8.2
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 +16 -7
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -349,6 +349,83 @@ describe("run scoping", () => {
|
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
describe("headless agent alive markers", () => {
|
|
353
|
+
let chunks: string[];
|
|
354
|
+
let originalWrite: typeof process.stdout.write;
|
|
355
|
+
|
|
356
|
+
beforeEach(() => {
|
|
357
|
+
chunks = [];
|
|
358
|
+
originalWrite = process.stdout.write;
|
|
359
|
+
process.stdout.write = ((chunk: string) => {
|
|
360
|
+
chunks.push(chunk);
|
|
361
|
+
return true;
|
|
362
|
+
}) as typeof process.stdout.write;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
process.stdout.write = originalWrite;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function output(): string {
|
|
370
|
+
return chunks.join("");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
test("printStatus shows green marker for headless agent with alive PID", () => {
|
|
374
|
+
// Use own process PID — guaranteed alive
|
|
375
|
+
const alivePid = process.pid;
|
|
376
|
+
const agent = makeAgent({
|
|
377
|
+
agentName: "headless-builder",
|
|
378
|
+
tmuxSession: "", // headless: no tmux
|
|
379
|
+
pid: alivePid,
|
|
380
|
+
state: "working",
|
|
381
|
+
});
|
|
382
|
+
const data = makeStatusData({
|
|
383
|
+
agents: [agent],
|
|
384
|
+
tmuxSessions: [], // no tmux sessions
|
|
385
|
+
});
|
|
386
|
+
printStatus(data);
|
|
387
|
+
const out = output();
|
|
388
|
+
// Green marker is ">" — check it appears in the output
|
|
389
|
+
expect(out).toContain("headless-builder");
|
|
390
|
+
expect(out).toContain(">");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("printStatus shows red marker for headless agent with dead PID", () => {
|
|
394
|
+
const deadPid = 2_147_483_647; // max int, virtually guaranteed non-existent
|
|
395
|
+
const agent = makeAgent({
|
|
396
|
+
agentName: "dead-headless-builder",
|
|
397
|
+
tmuxSession: "", // headless: no tmux
|
|
398
|
+
pid: deadPid,
|
|
399
|
+
state: "working",
|
|
400
|
+
});
|
|
401
|
+
const data = makeStatusData({
|
|
402
|
+
agents: [agent],
|
|
403
|
+
tmuxSessions: [],
|
|
404
|
+
});
|
|
405
|
+
printStatus(data);
|
|
406
|
+
const out = output();
|
|
407
|
+
expect(out).toContain("dead-headless-builder");
|
|
408
|
+
expect(out).toContain("x");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("printStatus uses tmux check (not PID) for tmux-based agents", () => {
|
|
412
|
+
const agent = makeAgent({
|
|
413
|
+
agentName: "tmux-builder",
|
|
414
|
+
tmuxSession: "overstory-test-builder",
|
|
415
|
+
pid: process.pid, // alive PID, but should use tmux check
|
|
416
|
+
state: "working",
|
|
417
|
+
});
|
|
418
|
+
// tmuxSessions empty → tmux dead → red marker
|
|
419
|
+
const data = makeStatusData({
|
|
420
|
+
agents: [agent],
|
|
421
|
+
tmuxSessions: [],
|
|
422
|
+
});
|
|
423
|
+
printStatus(data);
|
|
424
|
+
const out = output();
|
|
425
|
+
expect(out).toContain("x");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
352
429
|
describe("--watch deprecation", () => {
|
|
353
430
|
test("help text marks --watch as deprecated", async () => {
|
|
354
431
|
const chunks: string[] = [];
|
package/src/commands/status.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { openSessionStore } from "../sessions/compat.ts";
|
|
|
20
20
|
import type { AgentSession } from "../types.ts";
|
|
21
21
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
22
22
|
import { listWorktrees } from "../worktree/manager.ts";
|
|
23
|
-
import { listSessions } from "../worktree/tmux.ts";
|
|
23
|
+
import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Subprocess result cache (TTL-based, module-level)
|
|
@@ -260,8 +260,11 @@ export function printStatus(data: StatusData): void {
|
|
|
260
260
|
? new Date(agent.lastActivity).getTime()
|
|
261
261
|
: now;
|
|
262
262
|
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
263
|
-
const
|
|
264
|
-
const
|
|
263
|
+
const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
|
|
264
|
+
const alive = isHeadless
|
|
265
|
+
? agent.pid !== null && isProcessAlive(agent.pid)
|
|
266
|
+
: tmuxSessionNames.has(agent.tmuxSession);
|
|
267
|
+
const aliveMarker = alive ? color.green(">") : color.red("x");
|
|
265
268
|
w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
|
|
266
269
|
w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
|
|
267
270
|
|
|
@@ -20,6 +20,38 @@ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
|
20
20
|
import type { AgentSession } from "../types.ts";
|
|
21
21
|
import { type StopDeps, stopCommand } from "./stop.ts";
|
|
22
22
|
|
|
23
|
+
// --- Fake Process (for headless agents) ---
|
|
24
|
+
|
|
25
|
+
/** Track calls to fake process for assertions. */
|
|
26
|
+
interface ProcessCallTracker {
|
|
27
|
+
isAlive: Array<{ pid: number; result: boolean }>;
|
|
28
|
+
killTree: Array<{ pid: number }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a fake process DI object with configurable PID liveness. */
|
|
32
|
+
function makeFakeProcess(pidAliveMap: Record<number, boolean> = {}): {
|
|
33
|
+
proc: NonNullable<StopDeps["_process"]>;
|
|
34
|
+
calls: ProcessCallTracker;
|
|
35
|
+
} {
|
|
36
|
+
const calls: ProcessCallTracker = {
|
|
37
|
+
isAlive: [],
|
|
38
|
+
killTree: [],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const proc: NonNullable<StopDeps["_process"]> = {
|
|
42
|
+
isAlive: (pid: number): boolean => {
|
|
43
|
+
const alive = pidAliveMap[pid] ?? false;
|
|
44
|
+
calls.isAlive.push({ pid, result: alive });
|
|
45
|
+
return alive;
|
|
46
|
+
},
|
|
47
|
+
killTree: async (pid: number): Promise<void> => {
|
|
48
|
+
calls.killTree.push({ pid });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return { proc, calls };
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
// --- Fake Tmux ---
|
|
24
56
|
|
|
25
57
|
/** Track calls to fake tmux for assertions. */
|
|
@@ -405,3 +437,105 @@ describe("stopCommand --clean-worktree", () => {
|
|
|
405
437
|
expect(parsed.worktreeRemoved).toBe(false);
|
|
406
438
|
});
|
|
407
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("stopCommand headless agents", () => {
|
|
442
|
+
const HEADLESS_PID = 99999;
|
|
443
|
+
|
|
444
|
+
function makeHeadlessSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
445
|
+
return makeAgentSession({
|
|
446
|
+
tmuxSession: "",
|
|
447
|
+
pid: HEADLESS_PID,
|
|
448
|
+
...overrides,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function makeHeadlessDeps(
|
|
453
|
+
pidAliveMap: Record<number, boolean> = {},
|
|
454
|
+
worktreeConfig?: { shouldFail?: boolean },
|
|
455
|
+
): {
|
|
456
|
+
deps: StopDeps;
|
|
457
|
+
tmuxCalls: TmuxCallTracker;
|
|
458
|
+
procCalls: ProcessCallTracker;
|
|
459
|
+
worktreeCalls: WorktreeCallTracker;
|
|
460
|
+
} {
|
|
461
|
+
const { tmux, calls: tmuxCalls } = makeFakeTmux({});
|
|
462
|
+
const { proc, calls: procCalls } = makeFakeProcess(pidAliveMap);
|
|
463
|
+
const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
|
|
464
|
+
return {
|
|
465
|
+
deps: { _tmux: tmux, _worktree: worktree, _process: proc },
|
|
466
|
+
tmuxCalls,
|
|
467
|
+
procCalls,
|
|
468
|
+
worktreeCalls,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
test("stops headless agent by killing process tree (no tmux interaction)", async () => {
|
|
473
|
+
const session = makeHeadlessSession({ state: "working" });
|
|
474
|
+
saveSessionsToDb([session]);
|
|
475
|
+
|
|
476
|
+
const { deps, tmuxCalls, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
|
|
477
|
+
const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
|
|
478
|
+
|
|
479
|
+
// PID was killed
|
|
480
|
+
expect(procCalls.killTree).toHaveLength(1);
|
|
481
|
+
expect(procCalls.killTree[0]?.pid).toBe(HEADLESS_PID);
|
|
482
|
+
// Tmux was NOT touched
|
|
483
|
+
expect(tmuxCalls.isSessionAlive).toHaveLength(0);
|
|
484
|
+
expect(tmuxCalls.killSession).toHaveLength(0);
|
|
485
|
+
|
|
486
|
+
expect(output).toContain("Agent stopped");
|
|
487
|
+
expect(output).toContain("Process tree killed");
|
|
488
|
+
expect(output).toContain(String(HEADLESS_PID));
|
|
489
|
+
|
|
490
|
+
const { store } = openSessionStore(overstoryDir);
|
|
491
|
+
const updated = store.getByName("my-builder");
|
|
492
|
+
store.close();
|
|
493
|
+
expect(updated?.state).toBe("completed");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("handles headless agent with already-dead PID gracefully", async () => {
|
|
497
|
+
const session = makeHeadlessSession({ state: "working" });
|
|
498
|
+
saveSessionsToDb([session]);
|
|
499
|
+
|
|
500
|
+
// PID is NOT alive
|
|
501
|
+
const { deps, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: false });
|
|
502
|
+
const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
|
|
503
|
+
|
|
504
|
+
expect(procCalls.killTree).toHaveLength(0);
|
|
505
|
+
expect(output).toContain("Agent stopped");
|
|
506
|
+
expect(output).toContain("Process was already dead");
|
|
507
|
+
|
|
508
|
+
const { store } = openSessionStore(overstoryDir);
|
|
509
|
+
const updated = store.getByName("my-builder");
|
|
510
|
+
store.close();
|
|
511
|
+
expect(updated?.state).toBe("completed");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("--json output includes pidKilled for headless agent", async () => {
|
|
515
|
+
const session = makeHeadlessSession({ state: "working" });
|
|
516
|
+
saveSessionsToDb([session]);
|
|
517
|
+
|
|
518
|
+
const { deps } = makeHeadlessDeps({ [HEADLESS_PID]: true });
|
|
519
|
+
const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
|
|
520
|
+
|
|
521
|
+
const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
|
|
522
|
+
expect(parsed.success).toBe(true);
|
|
523
|
+
expect(parsed.stopped).toBe(true);
|
|
524
|
+
expect(parsed.pidKilled).toBe(true);
|
|
525
|
+
expect(parsed.tmuxKilled).toBe(false);
|
|
526
|
+
expect(parsed.agentName).toBe("my-builder");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("--clean-worktree works for headless agent", async () => {
|
|
530
|
+
const session = makeHeadlessSession({ state: "working" });
|
|
531
|
+
saveSessionsToDb([session]);
|
|
532
|
+
|
|
533
|
+
const { deps, worktreeCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
|
|
534
|
+
const output = await captureStdout(() =>
|
|
535
|
+
stopCommand("my-builder", { cleanWorktree: true }, deps),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
|
|
539
|
+
expect(worktreeCalls.remove).toHaveLength(1);
|
|
540
|
+
});
|
|
541
|
+
});
|
package/src/commands/stop.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Explicitly terminates a running agent by:
|
|
5
5
|
* 1. Looking up the agent session by name
|
|
6
|
-
*
|
|
6
|
+
* 2a. For TUI agents: killing its tmux session (if alive)
|
|
7
|
+
* 2b. For headless agents (tmuxSession === ''): sending SIGTERM to the process tree
|
|
7
8
|
* 3. Marking it as completed in the SessionStore
|
|
8
9
|
* 4. Optionally removing its worktree (--clean-worktree)
|
|
9
10
|
*/
|
|
@@ -15,7 +16,7 @@ import { jsonOutput } from "../json.ts";
|
|
|
15
16
|
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
16
17
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
17
18
|
import { removeWorktree } from "../worktree/manager.ts";
|
|
18
|
-
import { isSessionAlive, killSession } from "../worktree/tmux.ts";
|
|
19
|
+
import { isProcessAlive, isSessionAlive, killProcessTree, killSession } from "../worktree/tmux.ts";
|
|
19
20
|
|
|
20
21
|
export interface StopOptions {
|
|
21
22
|
force?: boolean;
|
|
@@ -36,6 +37,10 @@ export interface StopDeps {
|
|
|
36
37
|
options?: { force?: boolean; forceBranch?: boolean },
|
|
37
38
|
) => Promise<void>;
|
|
38
39
|
};
|
|
40
|
+
_process?: {
|
|
41
|
+
isAlive: (pid: number) => boolean;
|
|
42
|
+
killTree: (pid: number) => Promise<void>;
|
|
43
|
+
};
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -43,7 +48,7 @@ export interface StopDeps {
|
|
|
43
48
|
*
|
|
44
49
|
* @param agentName - Name of the agent to stop
|
|
45
50
|
* @param opts - Command options
|
|
46
|
-
* @param deps - Optional dependency injection for testing (tmux, worktree)
|
|
51
|
+
* @param deps - Optional dependency injection for testing (tmux, worktree, process)
|
|
47
52
|
*/
|
|
48
53
|
export async function stopCommand(
|
|
49
54
|
agentName: string,
|
|
@@ -63,6 +68,7 @@ export async function stopCommand(
|
|
|
63
68
|
|
|
64
69
|
const tmux = deps._tmux ?? { isSessionAlive, killSession };
|
|
65
70
|
const worktree = deps._worktree ?? { remove: removeWorktree };
|
|
71
|
+
const proc = deps._process ?? { isAlive: isProcessAlive, killTree: killProcessTree };
|
|
66
72
|
|
|
67
73
|
const cwd = process.cwd();
|
|
68
74
|
const config = await loadConfig(cwd);
|
|
@@ -84,10 +90,25 @@ export async function stopCommand(
|
|
|
84
90
|
throw new AgentError(`Agent "${agentName}" is already zombie (dead)`, { agentName });
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
const isHeadless = session.tmuxSession === "" && session.pid !== null;
|
|
94
|
+
|
|
95
|
+
let tmuxKilled = false;
|
|
96
|
+
let pidKilled = false;
|
|
97
|
+
|
|
98
|
+
if (isHeadless && session.pid !== null) {
|
|
99
|
+
// Headless agent: kill via process tree instead of tmux
|
|
100
|
+
const alive = proc.isAlive(session.pid);
|
|
101
|
+
if (alive) {
|
|
102
|
+
await proc.killTree(session.pid);
|
|
103
|
+
pidKilled = true;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// TUI agent: kill via tmux session
|
|
107
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
108
|
+
if (alive) {
|
|
109
|
+
await tmux.killSession(session.tmuxSession);
|
|
110
|
+
tmuxKilled = true;
|
|
111
|
+
}
|
|
91
112
|
}
|
|
92
113
|
|
|
93
114
|
// Mark session as completed
|
|
@@ -115,16 +136,25 @@ export async function stopCommand(
|
|
|
115
136
|
agentName,
|
|
116
137
|
sessionId: session.id,
|
|
117
138
|
capability: session.capability,
|
|
118
|
-
tmuxKilled
|
|
139
|
+
tmuxKilled,
|
|
140
|
+
pidKilled,
|
|
119
141
|
worktreeRemoved,
|
|
120
142
|
force,
|
|
121
143
|
});
|
|
122
144
|
} else {
|
|
123
145
|
printSuccess("Agent stopped", agentName);
|
|
124
|
-
if (
|
|
125
|
-
|
|
146
|
+
if (isHeadless) {
|
|
147
|
+
if (pidKilled) {
|
|
148
|
+
process.stdout.write(` Process tree killed: PID ${session.pid}\n`);
|
|
149
|
+
} else {
|
|
150
|
+
process.stdout.write(` Process was already dead (PID ${session.pid})\n`);
|
|
151
|
+
}
|
|
126
152
|
} else {
|
|
127
|
-
|
|
153
|
+
if (tmuxKilled) {
|
|
154
|
+
process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
|
|
155
|
+
} else {
|
|
156
|
+
process.stdout.write(` Tmux session was already dead\n`);
|
|
157
|
+
}
|
|
128
158
|
}
|
|
129
159
|
if (cleanWorktree && worktreeRemoved) {
|
|
130
160
|
process.stdout.write(` Worktree removed: ${session.worktreePath}\n`);
|
|
@@ -639,6 +639,10 @@ describe("traceCommand", () => {
|
|
|
639
639
|
"spawn",
|
|
640
640
|
"error",
|
|
641
641
|
"custom",
|
|
642
|
+
"turn_start",
|
|
643
|
+
"turn_end",
|
|
644
|
+
"progress",
|
|
645
|
+
"result",
|
|
642
646
|
] as const;
|
|
643
647
|
for (const eventType of eventTypes) {
|
|
644
648
|
store.insert(
|
|
@@ -664,6 +668,10 @@ describe("traceCommand", () => {
|
|
|
664
668
|
expect(out).toContain("SPAWN");
|
|
665
669
|
expect(out).toContain("ERROR");
|
|
666
670
|
expect(out).toContain("CUSTOM");
|
|
671
|
+
expect(out).toContain("TURN START");
|
|
672
|
+
expect(out).toContain("TURN END");
|
|
673
|
+
expect(out).toContain("PROGRESS");
|
|
674
|
+
expect(out).toContain("RESULT");
|
|
667
675
|
});
|
|
668
676
|
|
|
669
677
|
test("long data values are truncated", async () => {
|