@os-eco/overstory-cli 0.9.2 → 0.9.4
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 -0
- package/agents/coordinator.md +4 -4
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/beads/client.ts +31 -3
- package/src/commands/clean.ts +2 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.ts +4 -2
- package/src/commands/dashboard.ts +4 -1
- package/src/commands/doctor.ts +91 -76
- package/src/commands/init.ts +42 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/monitor.ts +10 -3
- package/src/commands/sling.test.ts +12 -0
- package/src/commands/sling.ts +10 -1
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +6 -4
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +20 -1
- package/src/doctor/consistency.ts +2 -2
- package/src/index.ts +1 -1
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/pi.test.ts +24 -0
- package/src/runtimes/pi.ts +4 -3
- package/src/runtimes/types.ts +7 -0
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/worktree/tmux.test.ts +202 -49
- package/src/worktree/tmux.ts +49 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
package/src/commands/init.ts
CHANGED
|
@@ -149,6 +149,39 @@ async function onboardTool(
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Known runtime CLI candidates in detection priority order.
|
|
154
|
+
* First installed runtime wins.
|
|
155
|
+
*/
|
|
156
|
+
const RUNTIME_CANDIDATES: Array<{ name: string; cli: string }> = [
|
|
157
|
+
{ name: "claude", cli: "claude" },
|
|
158
|
+
{ name: "copilot", cli: "copilot" },
|
|
159
|
+
{ name: "gemini", cli: "gemini" },
|
|
160
|
+
{ name: "opencode", cli: "opencode" },
|
|
161
|
+
{ name: "sapling", cli: "sp" },
|
|
162
|
+
{ name: "pi", cli: "pi" },
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Detect the default runtime by checking which coding agent CLIs are installed.
|
|
167
|
+
*
|
|
168
|
+
* Uses `which <cli>` via the spawner abstraction so detection is testable
|
|
169
|
+
* without real binaries on PATH. Returns the first installed runtime by
|
|
170
|
+
* priority order, or "claude" as the safe fallback.
|
|
171
|
+
*
|
|
172
|
+
* @param spawner - Spawner abstraction (defaults to Bun.spawn wrapper)
|
|
173
|
+
* @returns Runtime name suitable for config.runtime.default
|
|
174
|
+
*/
|
|
175
|
+
export async function detectDefaultRuntime(spawner: Spawner): Promise<string> {
|
|
176
|
+
for (const { name, cli } of RUNTIME_CANDIDATES) {
|
|
177
|
+
const result = await spawner(["which", cli]);
|
|
178
|
+
if (result.exitCode === 0) {
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return "claude";
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
/**
|
|
153
186
|
* Set up .gitattributes with merge=union entries for JSONL files.
|
|
154
187
|
*
|
|
@@ -739,6 +772,12 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
739
772
|
// 2. Detect project info
|
|
740
773
|
const projectName = opts.name ?? (await detectProjectName(projectRoot));
|
|
741
774
|
const canonicalBranch = await detectCanonicalBranch(projectRoot);
|
|
775
|
+
let defaultRuntime = "claude";
|
|
776
|
+
try {
|
|
777
|
+
defaultRuntime = await detectDefaultRuntime(spawner);
|
|
778
|
+
} catch {
|
|
779
|
+
// Non-fatal: fall back to claude if runtime detection fails
|
|
780
|
+
}
|
|
742
781
|
|
|
743
782
|
process.stdout.write(`Initializing overstory for "${projectName}"...\n\n`);
|
|
744
783
|
|
|
@@ -775,6 +814,9 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
775
814
|
config.project.name = projectName;
|
|
776
815
|
config.project.root = projectRoot;
|
|
777
816
|
config.project.canonicalBranch = canonicalBranch;
|
|
817
|
+
if (config.runtime) {
|
|
818
|
+
config.runtime.default = defaultRuntime;
|
|
819
|
+
}
|
|
778
820
|
|
|
779
821
|
const configYaml = serializeConfigToYaml(config);
|
|
780
822
|
const configPath = join(overstoryPath, "config.yaml");
|
package/src/commands/inspect.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { renderHeader, separator, stateIconColored } from "../logging/theme.ts";
|
|
|
18
18
|
import { createMetricsStore } from "../metrics/store.ts";
|
|
19
19
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
20
20
|
import type { AgentSession, StoredEvent, ToolStats } from "../types.ts";
|
|
21
|
+
import { TMUX_SOCKET } from "../worktree/tmux.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Extract current file from most recent Edit/Write/Read tool_start event.
|
|
@@ -72,10 +73,13 @@ function summarizeArgs(toolArgs: string | null): string {
|
|
|
72
73
|
*/
|
|
73
74
|
async function captureTmux(sessionName: string, lines: number): Promise<string | null> {
|
|
74
75
|
try {
|
|
75
|
-
const proc = Bun.spawn(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
const proc = Bun.spawn(
|
|
77
|
+
["tmux", "-L", TMUX_SOCKET, "capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`],
|
|
78
|
+
{
|
|
79
|
+
stdout: "pipe",
|
|
80
|
+
stderr: "pipe",
|
|
81
|
+
},
|
|
82
|
+
);
|
|
79
83
|
const exitCode = await proc.exited;
|
|
80
84
|
if (exitCode !== 0) {
|
|
81
85
|
return null;
|
package/src/commands/monitor.ts
CHANGED
|
@@ -25,7 +25,14 @@ import { printHint, printSuccess } from "../logging/color.ts";
|
|
|
25
25
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
26
26
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
27
27
|
import type { AgentSession } from "../types.ts";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createSession,
|
|
30
|
+
isSessionAlive,
|
|
31
|
+
killSession,
|
|
32
|
+
sanitizeTmuxName,
|
|
33
|
+
sendKeys,
|
|
34
|
+
TMUX_SOCKET,
|
|
35
|
+
} from "../worktree/tmux.ts";
|
|
29
36
|
import { isRunningAsRoot } from "./sling.ts";
|
|
30
37
|
|
|
31
38
|
/** Default monitor agent name. */
|
|
@@ -36,7 +43,7 @@ const MONITOR_NAME = "monitor";
|
|
|
36
43
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
37
44
|
*/
|
|
38
45
|
function monitorTmuxSession(projectName: string): string {
|
|
39
|
-
return `overstory-${projectName}-${MONITOR_NAME}`;
|
|
46
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${MONITOR_NAME}`;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -215,7 +222,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
|
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
if (shouldAttach) {
|
|
218
|
-
Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
|
|
225
|
+
Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
|
|
219
226
|
stdio: ["inherit", "inherit", "inherit"],
|
|
220
227
|
});
|
|
221
228
|
}
|
|
@@ -1038,6 +1038,18 @@ describe("sling provider env injection building blocks", () => {
|
|
|
1038
1038
|
expect(combined.OVERSTORY_TASK_ID).toBe("overstory-1234");
|
|
1039
1039
|
});
|
|
1040
1040
|
|
|
1041
|
+
test("env dict includes OVERSTORY_PROJECT_ROOT", () => {
|
|
1042
|
+
const env = { MODEL_KEY: "value" };
|
|
1043
|
+
const combined = {
|
|
1044
|
+
...env,
|
|
1045
|
+
OVERSTORY_AGENT_NAME: "test-builder",
|
|
1046
|
+
OVERSTORY_WORKTREE_PATH: "/path/to/wt",
|
|
1047
|
+
OVERSTORY_TASK_ID: "task-1",
|
|
1048
|
+
OVERSTORY_PROJECT_ROOT: "/path/to/project",
|
|
1049
|
+
};
|
|
1050
|
+
expect(combined.OVERSTORY_PROJECT_ROOT).toBe("/path/to/project");
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1041
1053
|
test("resolveModel returns no env for native anthropic provider", () => {
|
|
1042
1054
|
const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
|
|
1043
1055
|
const manifest = makeManifest();
|
package/src/commands/sling.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
ensureTmuxAvailable,
|
|
49
49
|
isSessionAlive,
|
|
50
50
|
killSession,
|
|
51
|
+
sanitizeTmuxName,
|
|
51
52
|
sendKeys,
|
|
52
53
|
waitForTuiReady,
|
|
53
54
|
} from "../worktree/tmux.ts";
|
|
@@ -806,6 +807,11 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
806
807
|
// Resolve runtime before overlayConfig so we can pass runtime.instructionPath
|
|
807
808
|
const runtime = getRuntime(opts.runtime, config, capability);
|
|
808
809
|
|
|
810
|
+
// Runtime-specific worktree preparation (e.g., Copilot folder trust)
|
|
811
|
+
if (runtime.prepareWorktree) {
|
|
812
|
+
await runtime.prepareWorktree(worktreePath);
|
|
813
|
+
}
|
|
814
|
+
|
|
809
815
|
const overlayConfig: OverlayConfig = {
|
|
810
816
|
agentName: name,
|
|
811
817
|
taskId: taskId,
|
|
@@ -919,6 +925,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
919
925
|
OVERSTORY_AGENT_NAME: name,
|
|
920
926
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
921
927
|
OVERSTORY_TASK_ID: taskId,
|
|
928
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
922
929
|
};
|
|
923
930
|
const argv = runtime.buildDirectSpawn({
|
|
924
931
|
cwd: worktreePath,
|
|
@@ -999,7 +1006,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
999
1006
|
await ensureTmuxAvailable();
|
|
1000
1007
|
|
|
1001
1008
|
// 12. Create tmux session running claude in interactive mode
|
|
1002
|
-
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
1009
|
+
const tmuxSessionName = `overstory-${sanitizeTmuxName(config.project.name)}-${name}`;
|
|
1003
1010
|
const spawnCmd = runtime.buildSpawnCommand({
|
|
1004
1011
|
model: resolvedModel.model,
|
|
1005
1012
|
permissionMode: "bypass",
|
|
@@ -1010,6 +1017,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1010
1017
|
OVERSTORY_AGENT_NAME: name,
|
|
1011
1018
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
1012
1019
|
OVERSTORY_TASK_ID: taskId,
|
|
1020
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
1013
1021
|
},
|
|
1014
1022
|
});
|
|
1015
1023
|
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
@@ -1017,6 +1025,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1017
1025
|
OVERSTORY_AGENT_NAME: name,
|
|
1018
1026
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
1019
1027
|
OVERSTORY_TASK_ID: taskId,
|
|
1028
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
1020
1029
|
});
|
|
1021
1030
|
|
|
1022
1031
|
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
createSession,
|
|
30
30
|
isSessionAlive,
|
|
31
31
|
killSession,
|
|
32
|
+
sanitizeTmuxName,
|
|
32
33
|
sendKeys,
|
|
33
34
|
waitForTuiReady,
|
|
34
35
|
} from "../worktree/tmux.ts";
|
|
@@ -170,7 +171,7 @@ async function startSupervisor(opts: {
|
|
|
170
171
|
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
171
172
|
// Inject the supervisor base definition via --append-system-prompt.
|
|
172
173
|
// Pass file path (not content) to avoid tmux "command too long" (overstory#45).
|
|
173
|
-
const tmuxSession = `overstory-${config.project.name}-supervisor-${opts.name}`;
|
|
174
|
+
const tmuxSession = `overstory-${sanitizeTmuxName(config.project.name)}-supervisor-${opts.name}`;
|
|
174
175
|
const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "supervisor.md");
|
|
175
176
|
const agentDefFile = Bun.file(agentDefPath);
|
|
176
177
|
let appendSystemPromptFile: string | undefined;
|
|
@@ -27,7 +27,6 @@ describe("watchCommand", () => {
|
|
|
27
27
|
let originalStderrWrite: typeof process.stderr.write;
|
|
28
28
|
let tempDir: string;
|
|
29
29
|
let originalCwd: string;
|
|
30
|
-
let originalExitCode: string | number | null | undefined;
|
|
31
30
|
|
|
32
31
|
beforeEach(async () => {
|
|
33
32
|
// Spy on stdout
|
|
@@ -46,8 +45,6 @@ describe("watchCommand", () => {
|
|
|
46
45
|
return true;
|
|
47
46
|
}) as typeof process.stderr.write;
|
|
48
47
|
|
|
49
|
-
// Save original exitCode
|
|
50
|
-
originalExitCode = process.exitCode;
|
|
51
48
|
process.exitCode = 0;
|
|
52
49
|
|
|
53
50
|
// Create temp dir with .overstory/config.yaml structure
|
|
@@ -66,7 +63,12 @@ describe("watchCommand", () => {
|
|
|
66
63
|
afterEach(async () => {
|
|
67
64
|
process.stdout.write = originalWrite;
|
|
68
65
|
process.stderr.write = originalStderrWrite;
|
|
69
|
-
|
|
66
|
+
// Unconditionally clear to 0 rather than restoring a captured "original" that
|
|
67
|
+
// could itself have been polluted by a parallel test file in the same bun
|
|
68
|
+
// process. `watchCommand` sets `process.exitCode = 1` as a side effect, so
|
|
69
|
+
// without this clear the 1 can leak all the way to bun test's shutdown and
|
|
70
|
+
// turn a fully-green run into exit 1.
|
|
71
|
+
process.exitCode = 0;
|
|
70
72
|
process.chdir(originalCwd);
|
|
71
73
|
await cleanupTempDir(tempDir);
|
|
72
74
|
});
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { ValidationError } from "../errors.ts";
|
|
6
7
|
import { createSessionStore } from "../sessions/store.ts";
|
|
7
8
|
import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
|
|
8
9
|
import type { AgentSession } from "../types.ts";
|
|
9
10
|
import { createWorktree } from "../worktree/manager.ts";
|
|
10
|
-
import { worktreeCommand } from "./worktree.ts";
|
|
11
|
+
import { checkLiveChildren, worktreeCommand } from "./worktree.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Tests for `overstory worktree` command.
|
|
@@ -974,5 +975,320 @@ describe("worktreeCommand", () => {
|
|
|
974
975
|
expect(parsed.cleaned).toContain(branch);
|
|
975
976
|
expect(parsed.seedsPreserved).toContain(branch);
|
|
976
977
|
});
|
|
978
|
+
|
|
979
|
+
describe("live-children guard", () => {
|
|
980
|
+
/**
|
|
981
|
+
* Write sessions into a nested .overstory/sessions.db inside a worktree.
|
|
982
|
+
* Simulates a lead worktree that has spawned builder children.
|
|
983
|
+
*/
|
|
984
|
+
function writeNestedSessions(worktreePath: string, sessions: AgentSession[]): void {
|
|
985
|
+
const nestedOverstory = join(worktreePath, ".overstory");
|
|
986
|
+
mkdirSync(nestedOverstory, { recursive: true });
|
|
987
|
+
const store = createSessionStore(join(nestedOverstory, "sessions.db"));
|
|
988
|
+
for (const s of sessions) {
|
|
989
|
+
store.upsert(s);
|
|
990
|
+
}
|
|
991
|
+
store.close();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
test("clean skipped when live children present (no --force)", async () => {
|
|
995
|
+
const worktreesDir = join(tempDir, ".overstory", "worktrees");
|
|
996
|
+
await mkdir(worktreesDir, { recursive: true });
|
|
997
|
+
|
|
998
|
+
const { path: wtPath } = await createWorktree({
|
|
999
|
+
repoRoot: tempDir,
|
|
1000
|
+
baseDir: worktreesDir,
|
|
1001
|
+
agentName: "lead-with-children",
|
|
1002
|
+
baseBranch: "main",
|
|
1003
|
+
taskId: "task-lead",
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// Parent session is completed
|
|
1007
|
+
writeSessionsToStore([
|
|
1008
|
+
makeSession({
|
|
1009
|
+
id: "session-lead",
|
|
1010
|
+
agentName: "lead-with-children",
|
|
1011
|
+
capability: "lead",
|
|
1012
|
+
worktreePath: wtPath,
|
|
1013
|
+
branchName: "overstory/lead-with-children/task-lead",
|
|
1014
|
+
taskId: "task-lead",
|
|
1015
|
+
state: "completed",
|
|
1016
|
+
}),
|
|
1017
|
+
]);
|
|
1018
|
+
|
|
1019
|
+
// Nested session with process.pid (guaranteed alive)
|
|
1020
|
+
writeNestedSessions(wtPath, [
|
|
1021
|
+
{
|
|
1022
|
+
id: "nested-builder",
|
|
1023
|
+
agentName: "nested-builder",
|
|
1024
|
+
capability: "builder",
|
|
1025
|
+
worktreePath: join(wtPath, ".overstory", "worktrees", "nested-builder"),
|
|
1026
|
+
branchName: "overstory/nested-builder/task-child",
|
|
1027
|
+
taskId: "task-child",
|
|
1028
|
+
tmuxSession: "overstory-nested-builder-fake",
|
|
1029
|
+
state: "working",
|
|
1030
|
+
pid: process.pid, // current process — guaranteed alive
|
|
1031
|
+
parentAgent: "lead-with-children",
|
|
1032
|
+
depth: 2,
|
|
1033
|
+
runId: null,
|
|
1034
|
+
startedAt: new Date().toISOString(),
|
|
1035
|
+
lastActivity: new Date().toISOString(),
|
|
1036
|
+
escalationLevel: 0,
|
|
1037
|
+
stalledSince: null,
|
|
1038
|
+
transcriptPath: null,
|
|
1039
|
+
},
|
|
1040
|
+
]);
|
|
1041
|
+
|
|
1042
|
+
await worktreeCommand(["clean", "--completed", "--json"]);
|
|
1043
|
+
const out = output();
|
|
1044
|
+
|
|
1045
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1046
|
+
cleaned: string[];
|
|
1047
|
+
blockedByChildren: string[];
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
expect(parsed.cleaned).toEqual([]);
|
|
1051
|
+
expect(parsed.blockedByChildren).toContain("overstory/lead-with-children/task-lead");
|
|
1052
|
+
// Worktree still exists
|
|
1053
|
+
expect(existsSync(wtPath)).toBe(true);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("clean proceeds when nested sessions are dead (pid unreachable)", async () => {
|
|
1057
|
+
const worktreesDir = join(tempDir, ".overstory", "worktrees");
|
|
1058
|
+
await mkdir(worktreesDir, { recursive: true });
|
|
1059
|
+
|
|
1060
|
+
const { path: wtPath } = await createWorktree({
|
|
1061
|
+
repoRoot: tempDir,
|
|
1062
|
+
baseDir: worktreesDir,
|
|
1063
|
+
agentName: "lead-dead-children",
|
|
1064
|
+
baseBranch: "main",
|
|
1065
|
+
taskId: "task-dead",
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
writeSessionsToStore([
|
|
1069
|
+
makeSession({
|
|
1070
|
+
id: "session-lead-dead",
|
|
1071
|
+
agentName: "lead-dead-children",
|
|
1072
|
+
capability: "lead",
|
|
1073
|
+
worktreePath: wtPath,
|
|
1074
|
+
branchName: "overstory/lead-dead-children/task-dead",
|
|
1075
|
+
taskId: "task-dead",
|
|
1076
|
+
state: "completed",
|
|
1077
|
+
}),
|
|
1078
|
+
]);
|
|
1079
|
+
|
|
1080
|
+
// Nested session with a dead pid (extremely high, will not exist)
|
|
1081
|
+
writeNestedSessions(wtPath, [
|
|
1082
|
+
{
|
|
1083
|
+
id: "nested-dead",
|
|
1084
|
+
agentName: "nested-dead",
|
|
1085
|
+
capability: "builder",
|
|
1086
|
+
worktreePath: join(wtPath, ".overstory", "worktrees", "nested-dead"),
|
|
1087
|
+
branchName: "overstory/nested-dead/task-dead-child",
|
|
1088
|
+
taskId: "task-dead-child",
|
|
1089
|
+
tmuxSession: "overstory-nested-dead-fake",
|
|
1090
|
+
state: "working",
|
|
1091
|
+
pid: 999999999, // dead pid
|
|
1092
|
+
parentAgent: "lead-dead-children",
|
|
1093
|
+
depth: 2,
|
|
1094
|
+
runId: null,
|
|
1095
|
+
startedAt: new Date().toISOString(),
|
|
1096
|
+
lastActivity: new Date().toISOString(),
|
|
1097
|
+
escalationLevel: 0,
|
|
1098
|
+
stalledSince: null,
|
|
1099
|
+
transcriptPath: null,
|
|
1100
|
+
},
|
|
1101
|
+
]);
|
|
1102
|
+
|
|
1103
|
+
await worktreeCommand(["clean", "--completed", "--json"]);
|
|
1104
|
+
const out = output();
|
|
1105
|
+
|
|
1106
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1107
|
+
cleaned: string[];
|
|
1108
|
+
blockedByChildren: string[];
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
expect(parsed.cleaned).toContain("overstory/lead-dead-children/task-dead");
|
|
1112
|
+
expect(parsed.blockedByChildren).toEqual([]);
|
|
1113
|
+
expect(existsSync(wtPath)).toBe(false);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("--force removes worktree even with live children", async () => {
|
|
1117
|
+
const worktreesDir = join(tempDir, ".overstory", "worktrees");
|
|
1118
|
+
await mkdir(worktreesDir, { recursive: true });
|
|
1119
|
+
|
|
1120
|
+
const { path: wtPath } = await createWorktree({
|
|
1121
|
+
repoRoot: tempDir,
|
|
1122
|
+
baseDir: worktreesDir,
|
|
1123
|
+
agentName: "lead-force",
|
|
1124
|
+
baseBranch: "main",
|
|
1125
|
+
taskId: "task-force-children",
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
writeSessionsToStore([
|
|
1129
|
+
makeSession({
|
|
1130
|
+
id: "session-lead-force",
|
|
1131
|
+
agentName: "lead-force",
|
|
1132
|
+
capability: "lead",
|
|
1133
|
+
worktreePath: wtPath,
|
|
1134
|
+
branchName: "overstory/lead-force/task-force-children",
|
|
1135
|
+
taskId: "task-force-children",
|
|
1136
|
+
state: "completed",
|
|
1137
|
+
}),
|
|
1138
|
+
]);
|
|
1139
|
+
|
|
1140
|
+
// Use a dead pid — avoids actually killing any live process,
|
|
1141
|
+
// but still exercises the --force code path.
|
|
1142
|
+
writeNestedSessions(wtPath, [
|
|
1143
|
+
{
|
|
1144
|
+
id: "nested-force",
|
|
1145
|
+
agentName: "nested-force",
|
|
1146
|
+
capability: "builder",
|
|
1147
|
+
worktreePath: join(wtPath, ".overstory", "worktrees", "nested-force"),
|
|
1148
|
+
branchName: "overstory/nested-force/task-force-child",
|
|
1149
|
+
taskId: "task-force-child",
|
|
1150
|
+
tmuxSession: "overstory-nested-force-fake",
|
|
1151
|
+
state: "working",
|
|
1152
|
+
pid: 999999999, // dead pid, safe to kill
|
|
1153
|
+
parentAgent: "lead-force",
|
|
1154
|
+
depth: 2,
|
|
1155
|
+
runId: null,
|
|
1156
|
+
startedAt: new Date().toISOString(),
|
|
1157
|
+
lastActivity: new Date().toISOString(),
|
|
1158
|
+
escalationLevel: 0,
|
|
1159
|
+
stalledSince: null,
|
|
1160
|
+
transcriptPath: null,
|
|
1161
|
+
},
|
|
1162
|
+
]);
|
|
1163
|
+
|
|
1164
|
+
await worktreeCommand(["clean", "--force", "--json"]);
|
|
1165
|
+
const out = output();
|
|
1166
|
+
|
|
1167
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1168
|
+
cleaned: string[];
|
|
1169
|
+
blockedByChildren: string[];
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
// Should be cleaned (not blocked) even though nested sessions existed
|
|
1173
|
+
expect(parsed.cleaned).toContain("overstory/lead-force/task-force-children");
|
|
1174
|
+
expect(existsSync(wtPath)).toBe(false);
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test("no nested .overstory — treated as no live children, clean proceeds", async () => {
|
|
1178
|
+
const worktreesDir = join(tempDir, ".overstory", "worktrees");
|
|
1179
|
+
await mkdir(worktreesDir, { recursive: true });
|
|
1180
|
+
|
|
1181
|
+
const { path: wtPath } = await createWorktree({
|
|
1182
|
+
repoRoot: tempDir,
|
|
1183
|
+
baseDir: worktreesDir,
|
|
1184
|
+
agentName: "lead-no-nested",
|
|
1185
|
+
baseBranch: "main",
|
|
1186
|
+
taskId: "task-no-nested",
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
writeSessionsToStore([
|
|
1190
|
+
makeSession({
|
|
1191
|
+
id: "session-lead-no-nested",
|
|
1192
|
+
agentName: "lead-no-nested",
|
|
1193
|
+
capability: "lead",
|
|
1194
|
+
worktreePath: wtPath,
|
|
1195
|
+
branchName: "overstory/lead-no-nested/task-no-nested",
|
|
1196
|
+
taskId: "task-no-nested",
|
|
1197
|
+
state: "completed",
|
|
1198
|
+
}),
|
|
1199
|
+
]);
|
|
1200
|
+
|
|
1201
|
+
// No nested .overstory/ directory written
|
|
1202
|
+
|
|
1203
|
+
await worktreeCommand(["clean", "--completed", "--json"]);
|
|
1204
|
+
const out = output();
|
|
1205
|
+
|
|
1206
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1207
|
+
cleaned: string[];
|
|
1208
|
+
blockedByChildren: string[];
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
expect(parsed.cleaned).toContain("overstory/lead-no-nested/task-no-nested");
|
|
1212
|
+
expect(parsed.blockedByChildren).toEqual([]);
|
|
1213
|
+
expect(existsSync(wtPath)).toBe(false);
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
describe("checkLiveChildren", () => {
|
|
1220
|
+
let tempDir: string;
|
|
1221
|
+
|
|
1222
|
+
beforeEach(async () => {
|
|
1223
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-checkchildren-"));
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
afterEach(async () => {
|
|
1227
|
+
await cleanupTempDir(tempDir);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test("returns empty array when no nested .overstory/sessions.db", async () => {
|
|
1231
|
+
const result = await checkLiveChildren(tempDir);
|
|
1232
|
+
expect(result).toEqual([]);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("returns empty array when all sessions are completed", async () => {
|
|
1236
|
+
const nestedOverstory = join(tempDir, ".overstory");
|
|
1237
|
+
mkdirSync(nestedOverstory, { recursive: true });
|
|
1238
|
+
const store = createSessionStore(join(nestedOverstory, "sessions.db"));
|
|
1239
|
+
store.upsert({
|
|
1240
|
+
id: "s1",
|
|
1241
|
+
agentName: "done-agent",
|
|
1242
|
+
capability: "builder",
|
|
1243
|
+
worktreePath: "/fake/wt",
|
|
1244
|
+
branchName: "overstory/done/task",
|
|
1245
|
+
taskId: "task",
|
|
1246
|
+
tmuxSession: "",
|
|
1247
|
+
state: "completed",
|
|
1248
|
+
pid: process.pid,
|
|
1249
|
+
parentAgent: null,
|
|
1250
|
+
depth: 2,
|
|
1251
|
+
runId: null,
|
|
1252
|
+
startedAt: new Date().toISOString(),
|
|
1253
|
+
lastActivity: new Date().toISOString(),
|
|
1254
|
+
escalationLevel: 0,
|
|
1255
|
+
stalledSince: null,
|
|
1256
|
+
transcriptPath: null,
|
|
1257
|
+
});
|
|
1258
|
+
store.close();
|
|
1259
|
+
|
|
1260
|
+
const result = await checkLiveChildren(tempDir);
|
|
1261
|
+
expect(result).toEqual([]);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("returns live children when working session with alive pid exists", async () => {
|
|
1265
|
+
const nestedOverstory = join(tempDir, ".overstory");
|
|
1266
|
+
mkdirSync(nestedOverstory, { recursive: true });
|
|
1267
|
+
const store = createSessionStore(join(nestedOverstory, "sessions.db"));
|
|
1268
|
+
store.upsert({
|
|
1269
|
+
id: "s1",
|
|
1270
|
+
agentName: "live-agent",
|
|
1271
|
+
capability: "builder",
|
|
1272
|
+
worktreePath: "/fake/wt",
|
|
1273
|
+
branchName: "overstory/live/task",
|
|
1274
|
+
taskId: "task",
|
|
1275
|
+
tmuxSession: "",
|
|
1276
|
+
state: "working",
|
|
1277
|
+
pid: process.pid, // current process — alive
|
|
1278
|
+
parentAgent: null,
|
|
1279
|
+
depth: 2,
|
|
1280
|
+
runId: null,
|
|
1281
|
+
startedAt: new Date().toISOString(),
|
|
1282
|
+
lastActivity: new Date().toISOString(),
|
|
1283
|
+
escalationLevel: 0,
|
|
1284
|
+
stalledSince: null,
|
|
1285
|
+
transcriptPath: null,
|
|
1286
|
+
});
|
|
1287
|
+
store.close();
|
|
1288
|
+
|
|
1289
|
+
const result = await checkLiveChildren(tempDir);
|
|
1290
|
+
expect(result).toHaveLength(1);
|
|
1291
|
+
expect(result[0]?.agentName).toBe("live-agent");
|
|
1292
|
+
expect(result[0]?.pid).toBe(process.pid);
|
|
977
1293
|
});
|
|
978
1294
|
});
|