@os-eco/overstory-cli 0.9.3 → 0.10.3
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 +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- 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 +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -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
|
});
|
package/src/commands/worktree.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Logs are never auto-deleted.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { Command } from "commander";
|
|
11
12
|
import { loadConfig } from "../config.ts";
|
|
@@ -14,6 +15,7 @@ import { jsonOutput } from "../json.ts";
|
|
|
14
15
|
import { printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
15
16
|
import { createMailStore } from "../mail/store.ts";
|
|
16
17
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
18
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
17
19
|
import type { AgentSession } from "../types.ts";
|
|
18
20
|
import {
|
|
19
21
|
isBranchMerged,
|
|
@@ -23,6 +25,51 @@ import {
|
|
|
23
25
|
} from "../worktree/manager.ts";
|
|
24
26
|
import { isSessionAlive, killSession } from "../worktree/tmux.ts";
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Check for live child sessions nested inside a worktree's own .overstory/sessions.db.
|
|
30
|
+
*
|
|
31
|
+
* Returns the live children (agentName + pid). Empty array if no nested DB or no live children.
|
|
32
|
+
*/
|
|
33
|
+
export async function checkLiveChildren(
|
|
34
|
+
worktreePath: string,
|
|
35
|
+
): Promise<{ agentName: string; pid: number }[]> {
|
|
36
|
+
const nestedDb = join(worktreePath, ".overstory", "sessions.db");
|
|
37
|
+
if (!existsSync(nestedDb)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const store = createSessionStore(nestedDb);
|
|
42
|
+
let sessions: AgentSession[];
|
|
43
|
+
try {
|
|
44
|
+
sessions = store.getAll();
|
|
45
|
+
} finally {
|
|
46
|
+
store.close();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deadStates = new Set(["completed", "zombie"]);
|
|
50
|
+
const liveChildren: { agentName: string; pid: number }[] = [];
|
|
51
|
+
|
|
52
|
+
for (const session of sessions) {
|
|
53
|
+
if (deadStates.has(session.state)) continue;
|
|
54
|
+
if (session.pid === null) continue;
|
|
55
|
+
|
|
56
|
+
// process.kill(pid, 0) throws if the process is dead (ESRCH)
|
|
57
|
+
let alive = false;
|
|
58
|
+
try {
|
|
59
|
+
process.kill(session.pid, 0);
|
|
60
|
+
alive = true;
|
|
61
|
+
} catch {
|
|
62
|
+
// ESRCH — process is dead
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (alive) {
|
|
66
|
+
liveChildren.push({ agentName: session.agentName, pid: session.pid });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return liveChildren;
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
/**
|
|
27
74
|
* Handle `ov worktree list`.
|
|
28
75
|
*/
|
|
@@ -100,6 +147,7 @@ async function handleClean(
|
|
|
100
147
|
const failed: string[] = [];
|
|
101
148
|
const skipped: string[] = [];
|
|
102
149
|
const seedsPreserved: string[] = [];
|
|
150
|
+
const blockedByChildren: string[] = [];
|
|
103
151
|
|
|
104
152
|
try {
|
|
105
153
|
for (const wt of overstoryWts) {
|
|
@@ -129,6 +177,33 @@ async function handleClean(
|
|
|
129
177
|
}
|
|
130
178
|
}
|
|
131
179
|
|
|
180
|
+
// Live-children guard: refuse to remove a worktree that has active nested agents.
|
|
181
|
+
// Nested sessions live in {wt.path}/.overstory/sessions.db (lead worktrees).
|
|
182
|
+
const liveChildren = await checkLiveChildren(wt.path);
|
|
183
|
+
if (liveChildren.length > 0) {
|
|
184
|
+
if (!force) {
|
|
185
|
+
blockedByChildren.push(wt.branch);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// --force: SIGTERM each live child, wait briefly, then proceed.
|
|
189
|
+
if (!json) {
|
|
190
|
+
printWarning(
|
|
191
|
+
`Force-terminating ${liveChildren.length} live child${liveChildren.length === 1 ? "" : "ren"} in ${wt.branch}`,
|
|
192
|
+
);
|
|
193
|
+
for (const child of liveChildren) {
|
|
194
|
+
process.stdout.write(` ${child.agentName} (pid ${child.pid})\n`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const child of liveChildren) {
|
|
198
|
+
try {
|
|
199
|
+
process.kill(child.pid, "SIGTERM");
|
|
200
|
+
} catch {
|
|
201
|
+
// Best effort
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await Bun.sleep(500);
|
|
205
|
+
}
|
|
206
|
+
|
|
132
207
|
// If --all, clean everything
|
|
133
208
|
// Kill tmux session if still alive
|
|
134
209
|
if (session?.tmuxSession) {
|
|
@@ -243,6 +318,7 @@ async function handleClean(
|
|
|
243
318
|
cleaned,
|
|
244
319
|
failed,
|
|
245
320
|
skipped,
|
|
321
|
+
blockedByChildren,
|
|
246
322
|
pruned: pruneCount,
|
|
247
323
|
mailPurged,
|
|
248
324
|
seedsPreserved,
|
|
@@ -252,6 +328,7 @@ async function handleClean(
|
|
|
252
328
|
pruneCount === 0 &&
|
|
253
329
|
failed.length === 0 &&
|
|
254
330
|
skipped.length === 0 &&
|
|
331
|
+
blockedByChildren.length === 0 &&
|
|
255
332
|
seedsPreserved.length === 0
|
|
256
333
|
) {
|
|
257
334
|
printHint("No worktrees to clean");
|
|
@@ -286,6 +363,15 @@ async function handleClean(
|
|
|
286
363
|
}
|
|
287
364
|
printHint("Use --force to delete unmerged branches");
|
|
288
365
|
}
|
|
366
|
+
if (blockedByChildren.length > 0) {
|
|
367
|
+
printWarning(
|
|
368
|
+
`Skipped ${blockedByChildren.length} worktree${blockedByChildren.length === 1 ? "" : "s"} with live child agents`,
|
|
369
|
+
);
|
|
370
|
+
for (const branch of blockedByChildren) {
|
|
371
|
+
process.stdout.write(` ${branch}\n`);
|
|
372
|
+
}
|
|
373
|
+
printHint("Use --force to cascade-terminate live children");
|
|
374
|
+
}
|
|
289
375
|
}
|
|
290
376
|
} finally {
|
|
291
377
|
store.close();
|
package/src/config.test.ts
CHANGED
|
@@ -1177,6 +1177,84 @@ describe("resolveProjectRoot", () => {
|
|
|
1177
1177
|
});
|
|
1178
1178
|
});
|
|
1179
1179
|
|
|
1180
|
+
describe("resolveProjectRoot — env var and walk-up", () => {
|
|
1181
|
+
let tempDir: string;
|
|
1182
|
+
let savedEnv: string | undefined;
|
|
1183
|
+
|
|
1184
|
+
beforeEach(async () => {
|
|
1185
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-envtest-"));
|
|
1186
|
+
savedEnv = process.env.OVERSTORY_PROJECT_ROOT;
|
|
1187
|
+
delete process.env.OVERSTORY_PROJECT_ROOT;
|
|
1188
|
+
clearProjectRootOverride();
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
afterEach(async () => {
|
|
1192
|
+
if (savedEnv !== undefined) {
|
|
1193
|
+
process.env.OVERSTORY_PROJECT_ROOT = savedEnv;
|
|
1194
|
+
} else {
|
|
1195
|
+
delete process.env.OVERSTORY_PROJECT_ROOT;
|
|
1196
|
+
}
|
|
1197
|
+
clearProjectRootOverride();
|
|
1198
|
+
await cleanupTempDir(tempDir);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("OVERSTORY_PROJECT_ROOT env var is returned immediately", async () => {
|
|
1202
|
+
await mkdir(join(tempDir, ".overstory"), { recursive: true });
|
|
1203
|
+
await Bun.write(
|
|
1204
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
1205
|
+
"project:\n canonicalBranch: main\n",
|
|
1206
|
+
);
|
|
1207
|
+
process.env.OVERSTORY_PROJECT_ROOT = tempDir;
|
|
1208
|
+
const result = await resolveProjectRoot("/some/unrelated/path");
|
|
1209
|
+
expect(result).toBe(tempDir);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test("env var beats walk-up worktree resolution", async () => {
|
|
1213
|
+
// Set up a parent root with config.yaml
|
|
1214
|
+
const parentRoot = tempDir;
|
|
1215
|
+
await mkdir(join(parentRoot, ".overstory", "worktrees", "some-agent"), { recursive: true });
|
|
1216
|
+
await Bun.write(
|
|
1217
|
+
join(parentRoot, ".overstory", "config.yaml"),
|
|
1218
|
+
"project:\n canonicalBranch: main\n",
|
|
1219
|
+
);
|
|
1220
|
+
const worktreePath = join(parentRoot, ".overstory", "worktrees", "some-agent");
|
|
1221
|
+
// Even though walk-up would resolve parentRoot, env var pointing elsewhere wins
|
|
1222
|
+
const envTarget = await mkdtemp(join(tmpdir(), "overstory-envtarget-"));
|
|
1223
|
+
try {
|
|
1224
|
+
process.env.OVERSTORY_PROJECT_ROOT = envTarget;
|
|
1225
|
+
const result = await resolveProjectRoot(worktreePath);
|
|
1226
|
+
expect(result).toBe(envTarget);
|
|
1227
|
+
} finally {
|
|
1228
|
+
await cleanupTempDir(envTarget);
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test("walk-up resolves submodule path without git", async () => {
|
|
1233
|
+
// Simulate a submodule worktree: {tempDir}/.overstory/worktrees/my-agent/sub
|
|
1234
|
+
// config.yaml exists at {tempDir}/.overstory/config.yaml
|
|
1235
|
+
const worktreeBase = join(tempDir, ".overstory", "worktrees", "my-agent");
|
|
1236
|
+
const subDir = join(worktreeBase, "sub");
|
|
1237
|
+
await mkdir(subDir, { recursive: true });
|
|
1238
|
+
await mkdir(join(tempDir, ".overstory"), { recursive: true });
|
|
1239
|
+
await Bun.write(
|
|
1240
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
1241
|
+
"project:\n canonicalBranch: main\n",
|
|
1242
|
+
);
|
|
1243
|
+
const result = await resolveProjectRoot(subDir);
|
|
1244
|
+
expect(result).toBe(tempDir);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test("walk-up is skipped when parent has no config.yaml", async () => {
|
|
1248
|
+
// Same path structure but NO config.yaml at parentRoot
|
|
1249
|
+
const worktreeBase = join(tempDir, ".overstory", "worktrees", "my-agent");
|
|
1250
|
+
await mkdir(worktreeBase, { recursive: true });
|
|
1251
|
+
// No config.yaml written — walk-up guard should prevent false resolution
|
|
1252
|
+
const result = await resolveProjectRoot(worktreeBase);
|
|
1253
|
+
// Falls through to startDir fallback
|
|
1254
|
+
expect(result).toBe(worktreeBase);
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1180
1258
|
describe("projectRootOverride", () => {
|
|
1181
1259
|
let tempDir: string;
|
|
1182
1260
|
|
package/src/config.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
|
|
|
90
90
|
rpcTimeoutMs: 5_000, // 5 seconds for RPC getState() calls
|
|
91
91
|
triageTimeoutMs: 30_000, // 30 seconds for Tier 1 AI triage calls
|
|
92
92
|
maxEscalationLevel: 3, // Maximum escalation level before termination
|
|
93
|
+
notifyParentOnDeath: true, // Send worker_died mail to parent when watchdog terminates a child
|
|
93
94
|
},
|
|
94
95
|
coordinator: {
|
|
95
96
|
exitTriggers: {
|
|
@@ -633,6 +634,16 @@ function validateConfig(config: OverstoryConfig): void {
|
|
|
633
634
|
}
|
|
634
635
|
}
|
|
635
636
|
|
|
637
|
+
if (
|
|
638
|
+
config.watchdog.notifyParentOnDeath !== undefined &&
|
|
639
|
+
typeof config.watchdog.notifyParentOnDeath !== "boolean"
|
|
640
|
+
) {
|
|
641
|
+
throw new ValidationError("watchdog.notifyParentOnDeath must be a boolean", {
|
|
642
|
+
field: "watchdog.notifyParentOnDeath",
|
|
643
|
+
value: config.watchdog.notifyParentOnDeath,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
636
647
|
// mulch.primeFormat must be one of the valid options
|
|
637
648
|
const validFormats = ["markdown", "xml", "json"] as const;
|
|
638
649
|
if (!validFormats.includes(config.mulch.primeFormat as (typeof validFormats)[number])) {
|
|
@@ -774,6 +785,18 @@ function validateConfig(config: OverstoryConfig): void {
|
|
|
774
785
|
}
|
|
775
786
|
}
|
|
776
787
|
|
|
788
|
+
// runtime.claudeHeadlessByDefault: must be a boolean if present
|
|
789
|
+
if (
|
|
790
|
+
config.runtime?.claudeHeadlessByDefault !== undefined &&
|
|
791
|
+
typeof config.runtime.claudeHeadlessByDefault !== "boolean"
|
|
792
|
+
) {
|
|
793
|
+
process.stderr.write(
|
|
794
|
+
`[overstory] WARNING: runtime.claudeHeadlessByDefault must be a boolean. Got: ${typeof config
|
|
795
|
+
.runtime.claudeHeadlessByDefault}. Ignoring.\n`,
|
|
796
|
+
);
|
|
797
|
+
config.runtime.claudeHeadlessByDefault = undefined;
|
|
798
|
+
}
|
|
799
|
+
|
|
777
800
|
if (config.runtime?.capabilities) {
|
|
778
801
|
for (const [cap, runtimeName] of Object.entries(config.runtime.capabilities)) {
|
|
779
802
|
if (runtimeName !== undefined && (typeof runtimeName !== "string" || runtimeName === "")) {
|
|
@@ -905,7 +928,26 @@ export async function resolveProjectRoot(startDir: string): Promise<string> {
|
|
|
905
928
|
|
|
906
929
|
const { existsSync } = require("node:fs") as typeof import("node:fs");
|
|
907
930
|
|
|
908
|
-
// Check
|
|
931
|
+
// Check OVERSTORY_PROJECT_ROOT env var. Zero-heuristic — injected by ov sling
|
|
932
|
+
// into agent environments so submodule topology doesn't matter.
|
|
933
|
+
const envRoot = process.env.OVERSTORY_PROJECT_ROOT;
|
|
934
|
+
if (envRoot && envRoot.length > 0) {
|
|
935
|
+
return envRoot;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Walk-up worktree-path detection. Topology-independent submodule fix:
|
|
939
|
+
// if startDir contains /.overstory/worktrees/ as a path segment, the
|
|
940
|
+
// substring before it is the project root — verify with config.yaml.
|
|
941
|
+
const WT_SEGMENT = `/${OVERSTORY_DIR}/worktrees/`;
|
|
942
|
+
const idx = startDir.indexOf(WT_SEGMENT);
|
|
943
|
+
if (idx > 0) {
|
|
944
|
+
const parentRoot = startDir.slice(0, idx);
|
|
945
|
+
if (existsSync(join(parentRoot, OVERSTORY_DIR, CONFIG_FILENAME))) {
|
|
946
|
+
return parentRoot;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Check git worktree. When running from an agent worktree
|
|
909
951
|
// (e.g., .overstory/worktrees/{name}/), the worktree may contain
|
|
910
952
|
// tracked copies of .overstory/config.yaml. We must resolve to the
|
|
911
953
|
// main repository root so runtime state (mail.db, metrics.db, etc.)
|