@os-eco/overstory-cli 0.9.3 → 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 +2 -0
- package/package.json +1 -1
- package/src/commands/clean.ts +2 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.ts +2 -1
- package/src/commands/doctor.ts +91 -76
- package/src/commands/monitor.ts +2 -1
- package/src/commands/sling.test.ts +12 -0
- package/src/commands/sling.ts +5 -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/worktree/tmux.test.ts +36 -0
- package/src/worktree/tmux.ts +13 -0
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ Overstory turns a single coding session into a multi-agent team by spawning work
|
|
|
10
10
|
|
|
11
11
|
> **Warning: Agent swarms are not a universal solution.** Do not deploy Overstory without understanding the risks of multi-agent orchestration — compounding error rates, cost amplification, debugging complexity, and merge conflicts are the normal case, not edge cases. Read [STEELMAN.md](STEELMAN.md) for a full risk analysis and the [Agentic Engineering Book](https://github.com/jayminwest/agentic-engineering-book) ([web version](https://jayminwest.com/agentic-engineering-book)) before using this tool in production.
|
|
12
12
|
|
|
13
|
+
> **Maintenance status.** Overstory is maintained part-time. PRs are reviewed in roughly 2-week batches; PRs inactive for 30+ days are closed (reopen anytime). For features larger than ~200 lines, open an issue or discussion first. See [CONTRIBUTING.md](CONTRIBUTING.md#review-cadence).
|
|
14
|
+
|
|
13
15
|
## Install
|
|
14
16
|
|
|
15
17
|
Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agent runtime must be installed:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-eco/overstory-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
|
|
5
5
|
"author": "Jaymin West",
|
|
6
6
|
"license": "MIT",
|
package/src/commands/clean.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
killProcessTree,
|
|
38
38
|
killSession,
|
|
39
39
|
listSessions,
|
|
40
|
+
sanitizeTmuxName,
|
|
40
41
|
} from "../worktree/tmux.ts";
|
|
41
42
|
|
|
42
43
|
export interface CleanOptions {
|
|
@@ -155,7 +156,7 @@ interface CleanResult {
|
|
|
155
156
|
*/
|
|
156
157
|
async function killAllTmuxSessions(overstoryDir: string, projectName: string): Promise<number> {
|
|
157
158
|
let killed = 0;
|
|
158
|
-
const projectPrefix = `overstory-${projectName}-`;
|
|
159
|
+
const projectPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
|
|
159
160
|
try {
|
|
160
161
|
const tmuxSessions = await listSessions();
|
|
161
162
|
const overStorySessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
|
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
} from "./completions.ts";
|
|
13
13
|
|
|
14
14
|
afterEach(() => {
|
|
15
|
-
|
|
15
|
+
// Use 0 not undefined — Bun doesn't reliably clear a nonzero exitCode when
|
|
16
|
+
// reassigned to undefined (see prior fix f3fde1a). If the 1 from completion
|
|
17
|
+
// tests leaks to bun test's shutdown, the suite exits 1 with 0 test failures.
|
|
18
|
+
process.exitCode = 0;
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
describe("COMMANDS array", () => {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
ensureTmuxAvailable,
|
|
38
38
|
isSessionAlive,
|
|
39
39
|
killSession,
|
|
40
|
+
sanitizeTmuxName,
|
|
40
41
|
sendKeys,
|
|
41
42
|
TMUX_SOCKET,
|
|
42
43
|
waitForTuiReady,
|
|
@@ -76,7 +77,7 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
|
|
|
76
77
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
77
78
|
*/
|
|
78
79
|
function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
|
|
79
|
-
return `overstory-${projectName}-${name}`;
|
|
80
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${name}`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Dependency injection for testing. Uses real implementations when omitted. */
|
package/src/commands/doctor.ts
CHANGED
|
@@ -158,10 +158,81 @@ export interface DoctorCommandOptions {
|
|
|
158
158
|
checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
interface DoctorActionOpts {
|
|
162
|
+
json?: boolean;
|
|
163
|
+
verbose?: boolean;
|
|
164
|
+
category?: string;
|
|
165
|
+
fix?: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
161
168
|
/**
|
|
162
|
-
*
|
|
169
|
+
* Run the doctor checks. Returns true if any check failed.
|
|
170
|
+
* Shared by both the Commander action and the programmatic entry point so the
|
|
171
|
+
* exit-code signal never has to travel through `process.exitCode` (which is
|
|
172
|
+
* global mutable state and races with other tests in parallel bun test runs).
|
|
163
173
|
*/
|
|
164
|
-
|
|
174
|
+
async function runDoctorChecks(
|
|
175
|
+
opts: DoctorActionOpts,
|
|
176
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const json = opts.json ?? false;
|
|
179
|
+
const verbose = opts.verbose ?? false;
|
|
180
|
+
const categoryFilter = opts.category;
|
|
181
|
+
const fix = opts.fix ?? false;
|
|
182
|
+
|
|
183
|
+
if (categoryFilter !== undefined) {
|
|
184
|
+
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
185
|
+
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
188
|
+
{
|
|
189
|
+
field: "category",
|
|
190
|
+
value: categoryFilter,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cwd = process.cwd();
|
|
197
|
+
const config = await loadConfig(cwd);
|
|
198
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
199
|
+
|
|
200
|
+
const checksToRun = categoryFilter
|
|
201
|
+
? checkRunners.filter((c) => c.category === categoryFilter)
|
|
202
|
+
: checkRunners;
|
|
203
|
+
|
|
204
|
+
let results: DoctorCheck[] = [];
|
|
205
|
+
for (const { fn } of checksToRun) {
|
|
206
|
+
const checkResults = await fn(config, overstoryDir);
|
|
207
|
+
results.push(...checkResults);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let fixedItems: string[] | undefined;
|
|
211
|
+
if (fix) {
|
|
212
|
+
const applied = await applyFixes(results);
|
|
213
|
+
if (applied.length > 0) {
|
|
214
|
+
fixedItems = applied;
|
|
215
|
+
results = [];
|
|
216
|
+
for (const { fn } of checksToRun) {
|
|
217
|
+
const checkResults = await fn(config, overstoryDir);
|
|
218
|
+
results.push(...checkResults);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (json) {
|
|
224
|
+
printJSON(results, fixedItems);
|
|
225
|
+
} else {
|
|
226
|
+
printHumanReadable(results, verbose, checkRunners, fixedItems);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return results.some((c) => c.status === "fail");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildDoctorCommand(
|
|
233
|
+
onResult: (hasFailures: boolean) => void,
|
|
234
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
235
|
+
): Command {
|
|
165
236
|
return new Command("doctor")
|
|
166
237
|
.description("Run health checks on overstory setup")
|
|
167
238
|
.option("--json", "Output as JSON")
|
|
@@ -172,73 +243,20 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
|
172
243
|
"after",
|
|
173
244
|
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
|
|
174
245
|
)
|
|
175
|
-
.action(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const categoryFilter = opts.category;
|
|
180
|
-
const fix = opts.fix ?? false;
|
|
181
|
-
|
|
182
|
-
// Validate category filter if provided
|
|
183
|
-
if (categoryFilter !== undefined) {
|
|
184
|
-
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
185
|
-
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
186
|
-
throw new ValidationError(
|
|
187
|
-
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
188
|
-
{
|
|
189
|
-
field: "category",
|
|
190
|
-
value: categoryFilter,
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const cwd = process.cwd();
|
|
197
|
-
const config = await loadConfig(cwd);
|
|
198
|
-
const overstoryDir = join(config.project.root, ".overstory");
|
|
199
|
-
|
|
200
|
-
// Filter checks by category if specified
|
|
201
|
-
const allChecks = options?.checkRunners ?? ALL_CHECKS;
|
|
202
|
-
const checksToRun = categoryFilter
|
|
203
|
-
? allChecks.filter((c) => c.category === categoryFilter)
|
|
204
|
-
: allChecks;
|
|
205
|
-
|
|
206
|
-
// Run all checks sequentially
|
|
207
|
-
let results: DoctorCheck[] = [];
|
|
208
|
-
for (const { fn } of checksToRun) {
|
|
209
|
-
const checkResults = await fn(config, overstoryDir);
|
|
210
|
-
results.push(...checkResults);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Apply fixes if requested
|
|
214
|
-
let fixedItems: string[] | undefined;
|
|
215
|
-
if (fix) {
|
|
216
|
-
const applied = await applyFixes(results);
|
|
217
|
-
if (applied.length > 0) {
|
|
218
|
-
fixedItems = applied;
|
|
219
|
-
// Re-run all checks to get fresh results after fixes
|
|
220
|
-
results = [];
|
|
221
|
-
for (const { fn } of checksToRun) {
|
|
222
|
-
const checkResults = await fn(config, overstoryDir);
|
|
223
|
-
results.push(...checkResults);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Output results
|
|
229
|
-
if (json) {
|
|
230
|
-
printJSON(results, fixedItems);
|
|
231
|
-
} else {
|
|
232
|
-
printHumanReadable(results, verbose, allChecks, fixedItems);
|
|
233
|
-
}
|
|
246
|
+
.action(async (opts: DoctorActionOpts) => {
|
|
247
|
+
onResult(await runDoctorChecks(opts, checkRunners));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Create the Commander command for `overstory doctor`.
|
|
253
|
+
*/
|
|
254
|
+
export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
255
|
+
return buildDoctorCommand((hasFailures) => {
|
|
256
|
+
if (hasFailures) {
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
}
|
|
259
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
/**
|
|
@@ -250,16 +268,15 @@ export async function doctorCommand(
|
|
|
250
268
|
args: string[],
|
|
251
269
|
options?: DoctorCommandOptions,
|
|
252
270
|
): Promise<number | undefined> {
|
|
253
|
-
|
|
271
|
+
let hasFailures = false;
|
|
272
|
+
const cmd = buildDoctorCommand((result) => {
|
|
273
|
+
hasFailures = result;
|
|
274
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
254
275
|
cmd.exitOverride();
|
|
255
276
|
|
|
256
|
-
const prevExitCode = process.exitCode as number | undefined;
|
|
257
|
-
process.exitCode = undefined;
|
|
258
|
-
|
|
259
277
|
try {
|
|
260
278
|
await cmd.parseAsync(args, { from: "user" });
|
|
261
279
|
} catch (err: unknown) {
|
|
262
|
-
process.exitCode = prevExitCode;
|
|
263
280
|
if (err && typeof err === "object" && "code" in err) {
|
|
264
281
|
const code = (err as { code: string }).code;
|
|
265
282
|
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
@@ -269,7 +286,5 @@ export async function doctorCommand(
|
|
|
269
286
|
throw err;
|
|
270
287
|
}
|
|
271
288
|
|
|
272
|
-
|
|
273
|
-
process.exitCode = prevExitCode;
|
|
274
|
-
return exitCode;
|
|
289
|
+
return hasFailures ? 1 : undefined;
|
|
275
290
|
}
|
package/src/commands/monitor.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
createSession,
|
|
30
30
|
isSessionAlive,
|
|
31
31
|
killSession,
|
|
32
|
+
sanitizeTmuxName,
|
|
32
33
|
sendKeys,
|
|
33
34
|
TMUX_SOCKET,
|
|
34
35
|
} from "../worktree/tmux.ts";
|
|
@@ -42,7 +43,7 @@ const MONITOR_NAME = "monitor";
|
|
|
42
43
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
43
44
|
*/
|
|
44
45
|
function monitorTmuxSession(projectName: string): string {
|
|
45
|
-
return `overstory-${projectName}-${MONITOR_NAME}`;
|
|
46
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${MONITOR_NAME}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -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";
|
|
@@ -924,6 +925,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
924
925
|
OVERSTORY_AGENT_NAME: name,
|
|
925
926
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
926
927
|
OVERSTORY_TASK_ID: taskId,
|
|
928
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
927
929
|
};
|
|
928
930
|
const argv = runtime.buildDirectSpawn({
|
|
929
931
|
cwd: worktreePath,
|
|
@@ -1004,7 +1006,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1004
1006
|
await ensureTmuxAvailable();
|
|
1005
1007
|
|
|
1006
1008
|
// 12. Create tmux session running claude in interactive mode
|
|
1007
|
-
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
1009
|
+
const tmuxSessionName = `overstory-${sanitizeTmuxName(config.project.name)}-${name}`;
|
|
1008
1010
|
const spawnCmd = runtime.buildSpawnCommand({
|
|
1009
1011
|
model: resolvedModel.model,
|
|
1010
1012
|
permissionMode: "bypass",
|
|
@@ -1015,6 +1017,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1015
1017
|
OVERSTORY_AGENT_NAME: name,
|
|
1016
1018
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
1017
1019
|
OVERSTORY_TASK_ID: taskId,
|
|
1020
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
1018
1021
|
},
|
|
1019
1022
|
});
|
|
1020
1023
|
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
@@ -1022,6 +1025,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1022
1025
|
OVERSTORY_AGENT_NAME: name,
|
|
1023
1026
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
1024
1027
|
OVERSTORY_TASK_ID: taskId,
|
|
1028
|
+
OVERSTORY_PROJECT_ROOT: config.project.root,
|
|
1025
1029
|
});
|
|
1026
1030
|
|
|
1027
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
|
});
|
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
|
@@ -905,7 +905,26 @@ export async function resolveProjectRoot(startDir: string): Promise<string> {
|
|
|
905
905
|
|
|
906
906
|
const { existsSync } = require("node:fs") as typeof import("node:fs");
|
|
907
907
|
|
|
908
|
-
// Check
|
|
908
|
+
// Check OVERSTORY_PROJECT_ROOT env var. Zero-heuristic — injected by ov sling
|
|
909
|
+
// into agent environments so submodule topology doesn't matter.
|
|
910
|
+
const envRoot = process.env.OVERSTORY_PROJECT_ROOT;
|
|
911
|
+
if (envRoot && envRoot.length > 0) {
|
|
912
|
+
return envRoot;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Walk-up worktree-path detection. Topology-independent submodule fix:
|
|
916
|
+
// if startDir contains /.overstory/worktrees/ as a path segment, the
|
|
917
|
+
// substring before it is the project root — verify with config.yaml.
|
|
918
|
+
const WT_SEGMENT = `/${OVERSTORY_DIR}/worktrees/`;
|
|
919
|
+
const idx = startDir.indexOf(WT_SEGMENT);
|
|
920
|
+
if (idx > 0) {
|
|
921
|
+
const parentRoot = startDir.slice(0, idx);
|
|
922
|
+
if (existsSync(join(parentRoot, OVERSTORY_DIR, CONFIG_FILENAME))) {
|
|
923
|
+
return parentRoot;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Check git worktree. When running from an agent worktree
|
|
909
928
|
// (e.g., .overstory/worktrees/{name}/), the worktree may contain
|
|
910
929
|
// tracked copies of .overstory/config.yaml. We must resolve to the
|
|
911
930
|
// main repository root so runtime state (mail.db, metrics.db, etc.)
|
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
4
4
|
import type { AgentSession, OverstoryConfig } from "../types.ts";
|
|
5
5
|
import { listWorktrees } from "../worktree/manager.ts";
|
|
6
|
-
import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
|
|
6
|
+
import { isProcessAlive, listSessions, sanitizeTmuxName } from "../worktree/tmux.ts";
|
|
7
7
|
import type { DoctorCheck } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -134,7 +134,7 @@ export async function checkConsistency(
|
|
|
134
134
|
|
|
135
135
|
// 5. Check for orphaned tmux sessions (tmux session exists but no SessionStore entry)
|
|
136
136
|
const projectName = config.project.name;
|
|
137
|
-
const overstoryTmuxPrefix = `overstory-${projectName}-`;
|
|
137
|
+
const overstoryTmuxPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
|
|
138
138
|
const overstoryTmuxSessions = tmuxSessions.filter((s) => s.name.startsWith(overstoryTmuxPrefix));
|
|
139
139
|
const storeTmuxNames = new Set(storeSessions.map((s) => s.tmuxSession));
|
|
140
140
|
|
package/src/index.ts
CHANGED
|
@@ -51,7 +51,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
51
51
|
import { jsonError } from "./json.ts";
|
|
52
52
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
53
53
|
|
|
54
|
-
export const VERSION = "0.9.
|
|
54
|
+
export const VERSION = "0.9.4";
|
|
55
55
|
|
|
56
56
|
const rawArgs = process.argv.slice(2);
|
|
57
57
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
killProcessTree,
|
|
14
14
|
killSession,
|
|
15
15
|
listSessions,
|
|
16
|
+
sanitizeTmuxName,
|
|
16
17
|
sendKeys,
|
|
17
18
|
waitForTuiReady,
|
|
18
19
|
} from "./tmux.ts";
|
|
@@ -1550,3 +1551,38 @@ describe("ensureTmuxAvailable", () => {
|
|
|
1550
1551
|
}
|
|
1551
1552
|
});
|
|
1552
1553
|
});
|
|
1554
|
+
|
|
1555
|
+
describe("sanitizeTmuxName", () => {
|
|
1556
|
+
test("replaces dots with underscores", () => {
|
|
1557
|
+
expect(sanitizeTmuxName("consulting.jayminwest.com")).toBe("consulting_jayminwest_com");
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
test("replaces colons with underscores", () => {
|
|
1561
|
+
expect(sanitizeTmuxName("host:8080")).toBe("host_8080");
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test("replaces mixed dots and colons", () => {
|
|
1565
|
+
expect(sanitizeTmuxName("my.project:v2.0")).toBe("my_project_v2_0");
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
test("leaves names without special characters unchanged", () => {
|
|
1569
|
+
expect(sanitizeTmuxName("my-project")).toBe("my-project");
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("handles empty string", () => {
|
|
1573
|
+
expect(sanitizeTmuxName("")).toBe("");
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
test("handles name with only dots", () => {
|
|
1577
|
+
expect(sanitizeTmuxName("...")).toBe("___");
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("produces valid tmux session name components", () => {
|
|
1581
|
+
// A real-world project name that would break tmux target parsing
|
|
1582
|
+
const projectName = "consulting.jayminwest.com";
|
|
1583
|
+
const sessionName = `overstory-${sanitizeTmuxName(projectName)}-coordinator`;
|
|
1584
|
+
expect(sessionName).toBe("overstory-consulting_jayminwest_com-coordinator");
|
|
1585
|
+
// No dots or colons that tmux would interpret as separators
|
|
1586
|
+
expect(sessionName).not.toMatch(/[.:]/);
|
|
1587
|
+
});
|
|
1588
|
+
});
|
package/src/worktree/tmux.ts
CHANGED
|
@@ -21,6 +21,19 @@ import type { ReadyState } from "../runtimes/types.ts";
|
|
|
21
21
|
*/
|
|
22
22
|
export const TMUX_SOCKET = "overstory";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a name component for use in tmux session names.
|
|
26
|
+
*
|
|
27
|
+
* Tmux interprets dots (.) as session.window.pane separators and colons (:)
|
|
28
|
+
* as session:window separators in target strings (`-t`). If a project name
|
|
29
|
+
* contains these characters (e.g., "consulting.jayminwest.com"), the session
|
|
30
|
+
* is created fine but subsequent lookups via `-t` parse the dots as delimiters
|
|
31
|
+
* and fail to find the session. Replace both with underscores.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeTmuxName(name: string): string {
|
|
34
|
+
return name.replace(/[.:]/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* Build a tmux command array with the dedicated server socket.
|
|
26
39
|
* All agent session operations should use this to ensure isolation.
|