@os-eco/overstory-cli 0.8.0 → 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 +3 -1
- package/package.json +1 -1
- 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/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/index.ts +1 -1
- 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
package/src/commands/sling.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 14. Return AgentSession
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { mkdirSync } from "node:fs";
|
|
21
22
|
import { mkdir } from "node:fs/promises";
|
|
22
23
|
import { join, resolve } from "node:path";
|
|
23
24
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
@@ -31,6 +32,7 @@ import { printSuccess } from "../logging/color.ts";
|
|
|
31
32
|
import { createMailClient } from "../mail/client.ts";
|
|
32
33
|
import { createMailStore } from "../mail/store.ts";
|
|
33
34
|
import { createMulchClient } from "../mulch/client.ts";
|
|
35
|
+
import { setConnection } from "../runtimes/connections.ts";
|
|
34
36
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
35
37
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
36
38
|
import { createRunStore } from "../sessions/store.ts";
|
|
@@ -38,6 +40,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
|
|
|
38
40
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
39
41
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
40
42
|
import { createWorktree } from "../worktree/manager.ts";
|
|
43
|
+
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
41
44
|
import {
|
|
42
45
|
capturePaneContent,
|
|
43
46
|
createSession,
|
|
@@ -836,142 +839,236 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
836
839
|
}
|
|
837
840
|
}
|
|
838
841
|
|
|
839
|
-
// 11c.
|
|
840
|
-
|
|
842
|
+
// 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
|
|
843
|
+
if (runtime.headless === true && runtime.buildDirectSpawn) {
|
|
844
|
+
const directEnv = {
|
|
845
|
+
...runtime.buildEnv(resolvedModel),
|
|
846
|
+
OVERSTORY_AGENT_NAME: name,
|
|
847
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
848
|
+
};
|
|
849
|
+
const argv = runtime.buildDirectSpawn({
|
|
850
|
+
cwd: worktreePath,
|
|
851
|
+
env: directEnv,
|
|
852
|
+
model: resolvedModel.model,
|
|
853
|
+
instructionPath: runtime.instructionPath,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Create a timestamped log dir for this headless agent session.
|
|
857
|
+
// Redirecting stdout/stderr to files prevents OS pipe buffer backpressure:
|
|
858
|
+
// when nobody reads the pipe, the child blocks on write() after ~64 KB and
|
|
859
|
+
// becomes a zombie. File writes have no such limit.
|
|
860
|
+
//
|
|
861
|
+
// Exception: RPC-capable runtimes need a live stdout pipe to receive
|
|
862
|
+
// JSON-RPC 2.0 responses (getState). In that case stdoutFile is omitted
|
|
863
|
+
// and the caller consumes the stream via the RuntimeConnection.
|
|
864
|
+
const hasRpcConnect = typeof runtime.connect === "function";
|
|
865
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
866
|
+
const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
|
|
867
|
+
mkdirSync(agentLogDir, { recursive: true });
|
|
868
|
+
|
|
869
|
+
const headlessProc = await spawnHeadlessAgent(argv, {
|
|
870
|
+
cwd: worktreePath,
|
|
871
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
872
|
+
stdoutFile: hasRpcConnect ? undefined : join(agentLogDir, "stdout.log"),
|
|
873
|
+
stderrFile: join(agentLogDir, "stderr.log"),
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Wire up RPC connection for runtimes that support it (e.g., Sapling).
|
|
877
|
+
// The connection is stored in the module-level registry so the watchdog
|
|
878
|
+
// and other subsystems can call getState() for health checks.
|
|
879
|
+
if (hasRpcConnect && headlessProc.stdout && runtime.connect) {
|
|
880
|
+
const connection = runtime.connect({
|
|
881
|
+
stdin: headlessProc.stdin,
|
|
882
|
+
stdout: headlessProc.stdout,
|
|
883
|
+
});
|
|
884
|
+
setConnection(name, connection);
|
|
885
|
+
}
|
|
841
886
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
887
|
+
// 13. Record session with empty tmuxSession (no tmux pane for headless agents).
|
|
888
|
+
const session: AgentSession = {
|
|
889
|
+
id: `session-${Date.now()}-${name}`,
|
|
890
|
+
agentName: name,
|
|
891
|
+
capability,
|
|
892
|
+
worktreePath,
|
|
893
|
+
branchName,
|
|
894
|
+
taskId: taskId,
|
|
895
|
+
tmuxSession: "",
|
|
896
|
+
state: "booting",
|
|
897
|
+
pid: headlessProc.pid,
|
|
898
|
+
parentAgent: parentAgent,
|
|
899
|
+
depth,
|
|
900
|
+
runId,
|
|
901
|
+
startedAt: new Date().toISOString(),
|
|
902
|
+
lastActivity: new Date().toISOString(),
|
|
903
|
+
escalationLevel: 0,
|
|
904
|
+
stalledSince: null,
|
|
905
|
+
transcriptPath: null,
|
|
906
|
+
};
|
|
907
|
+
store.upsert(session);
|
|
908
|
+
|
|
909
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
910
|
+
try {
|
|
911
|
+
runStore.incrementAgentCount(runId);
|
|
912
|
+
} finally {
|
|
913
|
+
runStore.close();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 14. Output result (headless)
|
|
917
|
+
if (opts.json ?? false) {
|
|
918
|
+
jsonOutput("sling", {
|
|
919
|
+
agentName: name,
|
|
920
|
+
capability,
|
|
921
|
+
taskId,
|
|
922
|
+
branch: branchName,
|
|
923
|
+
worktree: worktreePath,
|
|
924
|
+
tmuxSession: "",
|
|
925
|
+
pid: headlessProc.pid,
|
|
926
|
+
});
|
|
927
|
+
} else {
|
|
928
|
+
printSuccess("Agent launched (headless)", name);
|
|
929
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
930
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
931
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
932
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
933
|
+
}
|
|
934
|
+
} else {
|
|
935
|
+
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
936
|
+
await ensureTmuxAvailable();
|
|
937
|
+
|
|
938
|
+
// 12. Create tmux session running claude in interactive mode
|
|
939
|
+
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
940
|
+
const spawnCmd = runtime.buildSpawnCommand({
|
|
941
|
+
model: resolvedModel.model,
|
|
942
|
+
permissionMode: "bypass",
|
|
943
|
+
cwd: worktreePath,
|
|
944
|
+
env: {
|
|
945
|
+
...runtime.buildEnv(resolvedModel),
|
|
946
|
+
OVERSTORY_AGENT_NAME: name,
|
|
947
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
849
951
|
...runtime.buildEnv(resolvedModel),
|
|
850
952
|
OVERSTORY_AGENT_NAME: name,
|
|
851
953
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
852
|
-
}
|
|
853
|
-
});
|
|
854
|
-
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
855
|
-
...runtime.buildEnv(resolvedModel),
|
|
856
|
-
OVERSTORY_AGENT_NAME: name,
|
|
857
|
-
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
858
|
-
});
|
|
954
|
+
});
|
|
859
955
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
956
|
+
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
957
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
958
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
959
|
+
// leaving the agent stuck in "booting" (overstory-036f).
|
|
960
|
+
const session: AgentSession = {
|
|
961
|
+
id: `session-${Date.now()}-${name}`,
|
|
962
|
+
agentName: name,
|
|
963
|
+
capability,
|
|
964
|
+
worktreePath,
|
|
965
|
+
branchName,
|
|
966
|
+
taskId: taskId,
|
|
967
|
+
tmuxSession: tmuxSessionName,
|
|
968
|
+
state: "booting",
|
|
969
|
+
pid,
|
|
970
|
+
parentAgent: parentAgent,
|
|
971
|
+
depth,
|
|
972
|
+
runId,
|
|
973
|
+
startedAt: new Date().toISOString(),
|
|
974
|
+
lastActivity: new Date().toISOString(),
|
|
975
|
+
escalationLevel: 0,
|
|
976
|
+
stalledSince: null,
|
|
977
|
+
transcriptPath: null,
|
|
978
|
+
};
|
|
883
979
|
|
|
884
|
-
|
|
980
|
+
store.upsert(session);
|
|
885
981
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
982
|
+
// Increment agent count for the run
|
|
983
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
984
|
+
try {
|
|
985
|
+
runStore.incrementAgentCount(runId);
|
|
986
|
+
} finally {
|
|
987
|
+
runStore.close();
|
|
988
|
+
}
|
|
893
989
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
990
|
+
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
991
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
992
|
+
if (shellDelay > 0) {
|
|
993
|
+
await Bun.sleep(shellDelay);
|
|
994
|
+
}
|
|
899
995
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
996
|
+
// Wait for Claude Code TUI to render before sending input.
|
|
997
|
+
// Polling capture-pane is more reliable than a fixed sleep because
|
|
998
|
+
// TUI init time varies by machine load and model state.
|
|
999
|
+
await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
|
|
1000
|
+
// Buffer for the input handler to attach after initial render
|
|
1001
|
+
await Bun.sleep(1_000);
|
|
906
1002
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1003
|
+
const beacon = buildBeacon({
|
|
1004
|
+
agentName: name,
|
|
1005
|
+
capability,
|
|
1006
|
+
taskId,
|
|
1007
|
+
parentAgent,
|
|
1008
|
+
depth,
|
|
1009
|
+
instructionPath: runtime.instructionPath,
|
|
1010
|
+
});
|
|
1011
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1012
|
+
|
|
1013
|
+
// 13c. Follow-up Enters with increasing delays to ensure submission.
|
|
1014
|
+
// Claude Code's TUI may consume early Enters during late initialization
|
|
1015
|
+
// (overstory-yhv6). An Enter on an empty input line is harmless.
|
|
1016
|
+
for (const delay of [1_000, 2_000, 3_000, 5_000]) {
|
|
1017
|
+
await Bun.sleep(delay);
|
|
1018
|
+
await sendKeys(tmuxSessionName, "");
|
|
1019
|
+
}
|
|
924
1020
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1021
|
+
// 13d. Verify beacon was received — if pane still shows the welcome
|
|
1022
|
+
// screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
|
|
1023
|
+
// sometimes consumes the Enter keystroke during late initialization, swallowing
|
|
1024
|
+
// the beacon text entirely (overstory-3271).
|
|
1025
|
+
//
|
|
1026
|
+
// Skipped for runtimes that return false from requiresBeaconVerification().
|
|
1027
|
+
// Pi's TUI idle and processing states are indistinguishable via detectReady
|
|
1028
|
+
// (both show "pi v..." header and the token-usage status bar), so the loop
|
|
1029
|
+
// would incorrectly conclude the beacon was not received and spam duplicate
|
|
1030
|
+
// startup messages.
|
|
1031
|
+
const needsVerification =
|
|
1032
|
+
!runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
|
|
1033
|
+
if (needsVerification) {
|
|
1034
|
+
const verifyAttempts = 5;
|
|
1035
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
1036
|
+
await Bun.sleep(2_000);
|
|
1037
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
1038
|
+
if (paneContent) {
|
|
1039
|
+
const readyState = runtime.detectReady(paneContent);
|
|
1040
|
+
if (readyState.phase !== "ready") {
|
|
1041
|
+
break; // Agent is processing — beacon was received
|
|
1042
|
+
}
|
|
946
1043
|
}
|
|
1044
|
+
// Still at welcome/idle screen — resend beacon
|
|
1045
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1046
|
+
await Bun.sleep(1_000);
|
|
1047
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
947
1048
|
}
|
|
948
|
-
// Still at welcome/idle screen — resend beacon
|
|
949
|
-
await sendKeys(tmuxSessionName, beacon);
|
|
950
|
-
await Bun.sleep(1_000);
|
|
951
|
-
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
952
1049
|
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// 14. Output result
|
|
956
|
-
const output = {
|
|
957
|
-
agentName: name,
|
|
958
|
-
capability,
|
|
959
|
-
taskId,
|
|
960
|
-
branch: branchName,
|
|
961
|
-
worktree: worktreePath,
|
|
962
|
-
tmuxSession: tmuxSessionName,
|
|
963
|
-
pid,
|
|
964
|
-
};
|
|
965
1050
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1051
|
+
// 14. Output result
|
|
1052
|
+
const output = {
|
|
1053
|
+
agentName: name,
|
|
1054
|
+
capability,
|
|
1055
|
+
taskId,
|
|
1056
|
+
branch: branchName,
|
|
1057
|
+
worktree: worktreePath,
|
|
1058
|
+
tmuxSession: tmuxSessionName,
|
|
1059
|
+
pid,
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
if (opts.json ?? false) {
|
|
1063
|
+
jsonOutput("sling", output);
|
|
1064
|
+
} else {
|
|
1065
|
+
printSuccess("Agent launched", name);
|
|
1066
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1067
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1068
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1069
|
+
process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
|
|
1070
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
1071
|
+
}
|
|
975
1072
|
}
|
|
976
1073
|
} finally {
|
|
977
1074
|
store.close();
|
|
@@ -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
|
+
});
|