@os-eco/overstory-cli 0.9.4 → 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 +47 -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 +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- 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/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 +73 -1
- package/src/commands/sling.ts +149 -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/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- 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 +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -339,6 +339,114 @@ export function getTrackerCloseGuards(): HookEntry[] {
|
|
|
339
339
|
];
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Build a PreToolUse guard script that enforces the merge_ready gate on lead
|
|
344
|
+
* agents (overstory-3899, overstory-da9b): a lead may not run
|
|
345
|
+
* `sd/bd close $OVERSTORY_TASK_ID` unless (a) it has sent at least one
|
|
346
|
+
* `merge_ready` mail AND has sent at least one `merge_ready` per `worker_done`
|
|
347
|
+
* it has received, AND (b) the lead's branch (worktree HEAD) is reachable
|
|
348
|
+
* from the merge target (session-branch.txt > "main") via
|
|
349
|
+
* `git merge-base --is-ancestor`. (a) proves the lead reported completion;
|
|
350
|
+
* (b) proves the coordinator actually merged the work.
|
|
351
|
+
*
|
|
352
|
+
* Counts are derived by querying `ov mail list --json` and grep-counting
|
|
353
|
+
* `"id":"` occurrences in the JSON response (no jq dependency). The gate
|
|
354
|
+
* is a no-op for non-lead agents because it is only deployed to leads via
|
|
355
|
+
* `getLeadCloseGateGuards()`, but it still self-protects: the script
|
|
356
|
+
* exits early when OVERSTORY_AGENT_NAME or OVERSTORY_TASK_ID is unset.
|
|
357
|
+
* The merge-ancestor check fails open when OVERSTORY_WORKTREE_PATH is unset
|
|
358
|
+
* or the target ref cannot be resolved locally — in those cases we cannot
|
|
359
|
+
* make a definitive claim, so we don't block.
|
|
360
|
+
*
|
|
361
|
+
* Foreign-task closes are caught earlier by `buildTrackerCloseGuardScript`,
|
|
362
|
+
* so this gate only fires when the issue ID matches OVERSTORY_TASK_ID.
|
|
363
|
+
*/
|
|
364
|
+
export function buildLeadCloseGateScript(): string {
|
|
365
|
+
const blockNoMergeReady = JSON.stringify({
|
|
366
|
+
decision: "block",
|
|
367
|
+
reason:
|
|
368
|
+
'merge_ready gate: cannot close your task — you have not sent a merge_ready mail to coordinator. Required: ov mail send --to coordinator --subject "merge_ready: <task>" --body "<branch + files>" --type merge_ready --from $OVERSTORY_AGENT_NAME. Then retry the close.',
|
|
369
|
+
});
|
|
370
|
+
const blockUnderCount = JSON.stringify({
|
|
371
|
+
decision: "block",
|
|
372
|
+
reason:
|
|
373
|
+
"merge_ready gate: cannot close your task — merge_ready count is less than worker_done received. Send one merge_ready per worker_done before closing.",
|
|
374
|
+
});
|
|
375
|
+
const blockNotMerged = JSON.stringify({
|
|
376
|
+
decision: "block",
|
|
377
|
+
reason:
|
|
378
|
+
"merge_ready gate: cannot close your task — your branch is not yet merged into the target (session-branch.txt or main). Wait for the coordinator to merge before closing. The merge step is what makes the work real.",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const script = [
|
|
382
|
+
// Only enforce for overstory agent sessions
|
|
383
|
+
ENV_GUARD,
|
|
384
|
+
// Skip if task ID is not set (coordinator/monitor have no task)
|
|
385
|
+
'[ -z "$OVERSTORY_TASK_ID" ] && exit 0;',
|
|
386
|
+
"read -r INPUT;",
|
|
387
|
+
// Extract command value from JSON
|
|
388
|
+
'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
|
|
389
|
+
// Only inspect sd/bd close commands
|
|
390
|
+
"if ! echo \"$CMD\" | grep -qE '^\\s*(sd|bd)\\s+close\\s'; then exit 0; fi;",
|
|
391
|
+
// Extract the issue ID being closed
|
|
392
|
+
"ISSUE_ID=$(echo \"$CMD\" | sed -E 's/^[[:space:]]*(sd|bd)[[:space:]]+close[[:space:]]+([^ ]+).*/\\2/');",
|
|
393
|
+
// Only gate when the lead is closing its own task. Foreign closes are blocked by buildTrackerCloseGuardScript.
|
|
394
|
+
'[ "$ISSUE_ID" != "$OVERSTORY_TASK_ID" ] && exit 0;',
|
|
395
|
+
// Count merge_ready mails sent by this agent
|
|
396
|
+
'MR=$(ov mail list --json --from "$OVERSTORY_AGENT_NAME" --type merge_ready 2>/dev/null | grep -o \'"id":"\' | wc -l | tr -d \' \');',
|
|
397
|
+
// Count worker_done mails received by this agent
|
|
398
|
+
'WD=$(ov mail list --json --to "$OVERSTORY_AGENT_NAME" --type worker_done 2>/dev/null | grep -o \'"id":"\' | wc -l | tr -d \' \');',
|
|
399
|
+
// Default to 0 if the count failed for any reason.
|
|
400
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
|
|
401
|
+
"MR=${MR:-0}; WD=${WD:-0};",
|
|
402
|
+
// Block if no merge_ready was ever sent
|
|
403
|
+
'if [ "$MR" -eq 0 ]; then',
|
|
404
|
+
` echo '${escapeForSingleQuotedShell(blockNoMergeReady)}';`,
|
|
405
|
+
" exit 0;",
|
|
406
|
+
"fi;",
|
|
407
|
+
// Block if not enough merge_ready for the worker_done count
|
|
408
|
+
'if [ "$MR" -lt "$WD" ]; then',
|
|
409
|
+
` echo '${escapeForSingleQuotedShell(blockUnderCount)}';`,
|
|
410
|
+
" exit 0;",
|
|
411
|
+
"fi;",
|
|
412
|
+
// Verify the lead's branch is actually merged into the target (overstory-da9b).
|
|
413
|
+
// merge_ready alone doesn't prove the work landed — the coordinator may still be
|
|
414
|
+
// verifying or the merge may have failed.
|
|
415
|
+
// Skip if worktree path is missing (test envs etc.) — fail open.
|
|
416
|
+
'[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
|
|
417
|
+
// Resolve target branch: $OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt > "main"
|
|
418
|
+
'TARGET="";',
|
|
419
|
+
'if [ -n "$OVERSTORY_PROJECT_ROOT" ] && [ -f "$OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt" ]; then',
|
|
420
|
+
' TARGET=$(tr -d "[:space:]" < "$OVERSTORY_PROJECT_ROOT/.overstory/session-branch.txt" 2>/dev/null);',
|
|
421
|
+
"fi;",
|
|
422
|
+
'[ -z "$TARGET" ] && TARGET=main;',
|
|
423
|
+
// If the target ref doesn't exist locally, we can't verify — fail open.
|
|
424
|
+
'if ! git -C "$OVERSTORY_WORKTREE_PATH" rev-parse --verify "$TARGET" >/dev/null 2>&1; then exit 0; fi;',
|
|
425
|
+
// Block if HEAD is not yet an ancestor of the target.
|
|
426
|
+
'if ! git -C "$OVERSTORY_WORKTREE_PATH" merge-base --is-ancestor HEAD "$TARGET" >/dev/null 2>&1; then',
|
|
427
|
+
` echo '${escapeForSingleQuotedShell(blockNotMerged)}';`,
|
|
428
|
+
" exit 0;",
|
|
429
|
+
"fi;",
|
|
430
|
+
].join(" ");
|
|
431
|
+
return script;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Generate the lead-only PreToolUse guard that gates `sd/bd close <own-task>`
|
|
436
|
+
* on merge_ready emission. Wraps `buildLeadCloseGateScript` with the standard
|
|
437
|
+
* PATH_PREFIX so `ov` resolves under Claude Code's minimal hook PATH.
|
|
438
|
+
*
|
|
439
|
+
* Only deployed to lead agents (see getCapabilityGuards).
|
|
440
|
+
*/
|
|
441
|
+
export function getLeadCloseGateGuards(): HookEntry[] {
|
|
442
|
+
return [
|
|
443
|
+
{
|
|
444
|
+
matcher: "Bash",
|
|
445
|
+
hooks: [{ type: "command", command: `${PATH_PREFIX} ${buildLeadCloseGateScript()}` }],
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
342
450
|
/**
|
|
343
451
|
* Capabilities that are allowed to modify files via Bash commands.
|
|
344
452
|
* These get the Bash path boundary guard instead of a blanket file-modification block.
|
|
@@ -507,6 +615,13 @@ export function getCapabilityGuards(capability: string, qualityGates?: QualityGa
|
|
|
507
615
|
guards.push(...getBashPathBoundaryGuards());
|
|
508
616
|
}
|
|
509
617
|
|
|
618
|
+
// Lead agents get the merge_ready gate on sd/bd close (overstory-3899).
|
|
619
|
+
// Blocks closing the lead's own task unless at least one merge_ready mail
|
|
620
|
+
// has been sent and the count covers all worker_done received.
|
|
621
|
+
if (capability === "lead") {
|
|
622
|
+
guards.push(...getLeadCloseGateGuards());
|
|
623
|
+
}
|
|
624
|
+
|
|
510
625
|
return guards;
|
|
511
626
|
}
|
|
512
627
|
|
|
@@ -538,9 +653,23 @@ export function isOverstoryHookEntry(entry: HookEntry): boolean {
|
|
|
538
653
|
* Overstory hooks are placed before user hooks per event type so security
|
|
539
654
|
* guards run first.
|
|
540
655
|
*
|
|
656
|
+
* In `headlessOnly` mode, only PreToolUse hooks are deployed (overstory-e24b).
|
|
657
|
+
* Headless Claude Code (`-p --output-format stream-json`) DOES dispatch hooks
|
|
658
|
+
* from settings.local.json, so PreToolUse security guards (path boundary,
|
|
659
|
+
* capability blocks, bash danger patterns, tracker close, lead close gate)
|
|
660
|
+
* are required to keep parity with tmux mode. The other hook types are dropped
|
|
661
|
+
* because they have headless equivalents already wired up:
|
|
662
|
+
* - SessionStart → buildInitialHeadlessPrompt() in sling.ts
|
|
663
|
+
* - UserPromptSubmit → mail injection loop owned by `ov serve`
|
|
664
|
+
* - PostToolUse → stream-json parser captures tool_use/tool_result
|
|
665
|
+
* - Stop → stream-json parser captures the `result` event
|
|
666
|
+
* - PreCompact → deferred (tracked separately)
|
|
667
|
+
*
|
|
541
668
|
* @param worktreePath - Absolute path to the agent's git worktree (or project root)
|
|
542
669
|
* @param agentName - The unique name of the agent
|
|
543
670
|
* @param capability - Agent capability (builder, scout, reviewer, lead, merger)
|
|
671
|
+
* @param qualityGates - Quality gates whose commands are whitelisted as safe Bash prefixes
|
|
672
|
+
* @param headlessOnly - When true, deploy only PreToolUse entries (overstory-e24b)
|
|
544
673
|
* @throws {AgentError} If the template is not found or the write fails
|
|
545
674
|
*/
|
|
546
675
|
export async function deployHooks(
|
|
@@ -548,6 +677,7 @@ export async function deployHooks(
|
|
|
548
677
|
agentName: string,
|
|
549
678
|
capability = "builder",
|
|
550
679
|
qualityGates?: QualityGate[],
|
|
680
|
+
headlessOnly = false,
|
|
551
681
|
): Promise<void> {
|
|
552
682
|
const templatePath = getTemplatePath();
|
|
553
683
|
const file = Bun.file(templatePath);
|
|
@@ -578,6 +708,17 @@ export async function deployHooks(
|
|
|
578
708
|
// Parse the base config from the template
|
|
579
709
|
const config = JSON.parse(content) as { hooks: Record<string, HookEntry[]> };
|
|
580
710
|
|
|
711
|
+
// Headless mode: drop all template-derived hook entries.
|
|
712
|
+
// Under spawn-per-turn (Phase 3, overstory-2cf9), the turn-runner provides
|
|
713
|
+
// the user prompt and emits its own observability events for every turn;
|
|
714
|
+
// the template's SessionStart/UserPromptSubmit/PostToolUse/Stop/PreCompact
|
|
715
|
+
// hooks would either double-deliver mail (UserPromptSubmit re-injects on top
|
|
716
|
+
// of the runner's prompt) or duplicate session_end / per-tool events.
|
|
717
|
+
// Only the dynamic PreToolUse security guards added below are retained.
|
|
718
|
+
if (headlessOnly) {
|
|
719
|
+
config.hooks = {};
|
|
720
|
+
}
|
|
721
|
+
|
|
581
722
|
// Extend PATH in all template hook commands.
|
|
582
723
|
// Claude Code invokes hooks with PATH=/usr/bin:/bin:/usr/sbin:/sbin — ~/.bun/bin
|
|
583
724
|
// (where ov, ml, sd, etc. live) is not included. Prepend PATH_PREFIX so CLIs resolve.
|
|
@@ -523,7 +523,7 @@ describe("generateOverlay", () => {
|
|
|
523
523
|
expect(output).toContain("3");
|
|
524
524
|
});
|
|
525
525
|
|
|
526
|
-
test("dispatch overrides: maxAgentsOverride of 1
|
|
526
|
+
test("dispatch overrides: maxAgentsOverride of 1 directs the lead to spend the slot on a single builder", async () => {
|
|
527
527
|
const config = makeConfig({
|
|
528
528
|
capability: "lead",
|
|
529
529
|
maxAgentsOverride: 1,
|
|
@@ -532,8 +532,8 @@ describe("generateOverlay", () => {
|
|
|
532
532
|
const output = await generateOverlay(config);
|
|
533
533
|
|
|
534
534
|
expect(output).toContain("MAX AGENTS");
|
|
535
|
-
expect(output).toContain("
|
|
536
|
-
expect(output).toContain("
|
|
535
|
+
expect(output).toContain("single builder");
|
|
536
|
+
expect(output).toContain("Leads cannot implement directly");
|
|
537
537
|
});
|
|
538
538
|
|
|
539
539
|
test("dispatch overrides: maxAgentsOverride of 2 enables compressed-mode guidance", async () => {
|
|
@@ -546,7 +546,7 @@ describe("generateOverlay", () => {
|
|
|
546
546
|
|
|
547
547
|
expect(output).toContain("MAX AGENTS");
|
|
548
548
|
expect(output).toContain("compressed mode");
|
|
549
|
-
expect(output).toContain("
|
|
549
|
+
expect(output).toContain("Leads do not implement");
|
|
550
550
|
});
|
|
551
551
|
|
|
552
552
|
test("dispatch overrides: both skipReview and maxAgentsOverride together", async () => {
|
package/src/agents/overlay.ts
CHANGED
|
@@ -3,6 +3,26 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { DEFAULT_QUALITY_GATES } from "../config.ts";
|
|
4
4
|
import { AgentError } from "../errors.ts";
|
|
5
5
|
import type { OverlayConfig, QualityGate } from "../types.ts";
|
|
6
|
+
import { terminalMailTypesFor } from "./capabilities.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Capability-specific completion-mail guidance for the dynamic overlay.
|
|
10
|
+
*
|
|
11
|
+
* Returns the terminal mail-type name and a one-line example fragment so the
|
|
12
|
+
* overlay can render: "ov mail send ... --type <terminalType> ...".
|
|
13
|
+
*
|
|
14
|
+
* Crucial: this MUST stay in sync with `terminalMailTypesFor()` — overstory-1a4c
|
|
15
|
+
* found that overlay text saying `--type result` while the runner watched only
|
|
16
|
+
* for `worker_done` left worker sessions stuck in `working`.
|
|
17
|
+
*/
|
|
18
|
+
function completionMailTypeFor(capability: string): string {
|
|
19
|
+
const types = terminalMailTypesFor(capability);
|
|
20
|
+
// `terminalMailTypesFor` returns the canonical type first
|
|
21
|
+
// (worker_done for workers, merged for mergers). Use that for prose;
|
|
22
|
+
// agents may also use the secondary types (`merge_failed`, etc.) where
|
|
23
|
+
// applicable per their base prompt.
|
|
24
|
+
return types[0] ?? "worker_done";
|
|
25
|
+
}
|
|
6
26
|
|
|
7
27
|
/**
|
|
8
28
|
* Resolve the path to the overlay template file.
|
|
@@ -105,14 +125,14 @@ function formatDispatchOverrides(config: OverlayConfig): string {
|
|
|
105
125
|
if (config.maxAgentsOverride === 1) {
|
|
106
126
|
sections.push(
|
|
107
127
|
"- **MAX AGENTS**: Your per-lead agent ceiling has been set to **1**. " +
|
|
108
|
-
"
|
|
109
|
-
"
|
|
128
|
+
"Spend that slot on a single builder for the whole task — skip scouts and reviewers and self-verify the builder's diff yourself. " +
|
|
129
|
+
"Leads cannot implement directly (Write/Edit/`git add`/`git commit` are blocked by the harness), so the one slot must be a builder.",
|
|
110
130
|
);
|
|
111
131
|
} else if (config.maxAgentsOverride === 2) {
|
|
112
132
|
sections.push(
|
|
113
133
|
"- **MAX AGENTS**: Your per-lead agent ceiling has been set to **2**. " +
|
|
114
|
-
"Operate in compressed mode:
|
|
115
|
-
"
|
|
134
|
+
"Operate in compressed mode: spend the slots on builders (one or two), skip scouts and reviewers, and self-verify each diff yourself. " +
|
|
135
|
+
"Leads do not implement; every change requires a builder spawn.",
|
|
116
136
|
);
|
|
117
137
|
} else {
|
|
118
138
|
sections.push(
|
|
@@ -202,14 +222,15 @@ export function formatQualityGatesCapabilities(gates: QualityGate[] | undefined)
|
|
|
202
222
|
|
|
203
223
|
function formatQualityGates(config: OverlayConfig): string {
|
|
204
224
|
if (READ_ONLY_CAPABILITIES.has(config.capability)) {
|
|
225
|
+
const completionType = completionMailTypeFor(config.capability);
|
|
205
226
|
return [
|
|
206
227
|
"## Completion",
|
|
207
228
|
"",
|
|
208
229
|
"Before reporting completion:",
|
|
209
230
|
"",
|
|
210
231
|
`1. **Record mulch learnings:** \`ml record <domain> --type <convention|pattern|reference> --description "..."\` — capture reusable knowledge from your work`,
|
|
211
|
-
`2. **
|
|
212
|
-
`3. **
|
|
232
|
+
`2. **Signal completion:** send \`${completionType}\` mail to ${config.parentAgent ?? "coordinator"}: \`ov mail send --to ${config.parentAgent ?? "coordinator"} --subject "Worker done: ${config.taskId}" --body "Summary of findings" --type ${completionType} --agent ${config.agentName}\``,
|
|
233
|
+
`3. **Close issue:** \`${config.trackerCli ?? "sd"} close ${config.taskId} --reason "summary of findings"\``,
|
|
213
234
|
"",
|
|
214
235
|
"You are a read-only agent. Do NOT commit, modify files, or run quality gates.",
|
|
215
236
|
].join("\n");
|
|
@@ -245,13 +266,14 @@ function formatQualityGates(config: OverlayConfig): string {
|
|
|
245
266
|
* Writable agents get file-scope and branch constraints.
|
|
246
267
|
*/
|
|
247
268
|
function formatConstraints(config: OverlayConfig): string {
|
|
269
|
+
const completionType = completionMailTypeFor(config.capability);
|
|
248
270
|
if (READ_ONLY_CAPABILITIES.has(config.capability)) {
|
|
249
271
|
return [
|
|
250
272
|
"## Constraints",
|
|
251
273
|
"",
|
|
252
274
|
"- You are **read-only**: do NOT modify, create, or delete any files",
|
|
253
275
|
"- Do NOT commit, push, or make any git state changes",
|
|
254
|
-
`- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type
|
|
276
|
+
`- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type ${completionType}\``,
|
|
255
277
|
"- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
|
|
256
278
|
].join("\n");
|
|
257
279
|
}
|
|
@@ -264,7 +286,7 @@ function formatConstraints(config: OverlayConfig): string {
|
|
|
264
286
|
"- Only modify files in your File Scope",
|
|
265
287
|
`- Commit only to your branch: ${config.branchName}`,
|
|
266
288
|
"- Never push to the canonical branch",
|
|
267
|
-
`- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type
|
|
289
|
+
`- Report completion via \`${config.trackerCli ?? "sd"} close\` AND \`ov mail send --type ${completionType}\``,
|
|
268
290
|
"- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
|
|
269
291
|
].join("\n");
|
|
270
292
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
_resetInProcessLocks,
|
|
7
|
+
acquireTurnLock,
|
|
8
|
+
readTurnLock,
|
|
9
|
+
turnLockDbPath,
|
|
10
|
+
} from "./turn-lock.ts";
|
|
11
|
+
|
|
12
|
+
describe("turn-lock", () => {
|
|
13
|
+
let overstoryDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
overstoryDir = await mkdtemp(join(tmpdir(), "overstory-turnlock-test-"));
|
|
17
|
+
_resetInProcessLocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
_resetInProcessLocks();
|
|
22
|
+
await rm(overstoryDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("turnLockDbPath joins overstory dir + turn-locks.db", () => {
|
|
26
|
+
expect(turnLockDbPath("/tmp/overstory")).toBe("/tmp/overstory/turn-locks.db");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("acquire creates the row and records holder pid", async () => {
|
|
30
|
+
const handle = await acquireTurnLock({ agentName: "alpha", overstoryDir });
|
|
31
|
+
try {
|
|
32
|
+
const state = readTurnLock(overstoryDir, "alpha");
|
|
33
|
+
expect(state.heldByPid).toBe(process.pid);
|
|
34
|
+
expect(state.acquiredAt).toBeTruthy();
|
|
35
|
+
} finally {
|
|
36
|
+
handle.release();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("release clears the row and is idempotent", async () => {
|
|
41
|
+
const handle = await acquireTurnLock({ agentName: "alpha", overstoryDir });
|
|
42
|
+
handle.release();
|
|
43
|
+
// Calling release a second time must not throw.
|
|
44
|
+
handle.release();
|
|
45
|
+
const state = readTurnLock(overstoryDir, "alpha");
|
|
46
|
+
expect(state.heldByPid).toBeNull();
|
|
47
|
+
expect(state.acquiredAt).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("two acquires for the same agent serialize via in-process queue", async () => {
|
|
51
|
+
// Track entry/exit windows via timestamps. The second call must start
|
|
52
|
+
// AFTER the first releases, never overlap.
|
|
53
|
+
const events: Array<{ id: number; phase: "enter" | "exit"; ts: number }> = [];
|
|
54
|
+
|
|
55
|
+
const work = async (id: number, holdMs: number): Promise<void> => {
|
|
56
|
+
const handle = await acquireTurnLock({ agentName: "shared", overstoryDir });
|
|
57
|
+
events.push({ id, phase: "enter", ts: Date.now() });
|
|
58
|
+
await Bun.sleep(holdMs);
|
|
59
|
+
events.push({ id, phase: "exit", ts: Date.now() });
|
|
60
|
+
handle.release();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await Promise.all([work(1, 100), work(2, 50)]);
|
|
64
|
+
|
|
65
|
+
// Sort events by timestamp; verify each acquire's enter follows the
|
|
66
|
+
// previous holder's exit.
|
|
67
|
+
const ordered = [...events].sort((a, b) => a.ts - b.ts);
|
|
68
|
+
expect(ordered.length).toBe(4);
|
|
69
|
+
expect(ordered[0]?.phase).toBe("enter");
|
|
70
|
+
expect(ordered[1]?.phase).toBe("exit");
|
|
71
|
+
expect(ordered[1]?.id).toBe(ordered[0]?.id);
|
|
72
|
+
expect(ordered[2]?.phase).toBe("enter");
|
|
73
|
+
expect(ordered[3]?.phase).toBe("exit");
|
|
74
|
+
expect(ordered[3]?.id).toBe(ordered[2]?.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("acquires for different agents proceed concurrently", async () => {
|
|
78
|
+
// Both calls should overlap because the in-process map is keyed per agent.
|
|
79
|
+
let active = 0;
|
|
80
|
+
let maxActive = 0;
|
|
81
|
+
const work = async (name: string): Promise<void> => {
|
|
82
|
+
const handle = await acquireTurnLock({ agentName: name, overstoryDir });
|
|
83
|
+
active++;
|
|
84
|
+
maxActive = Math.max(maxActive, active);
|
|
85
|
+
await Bun.sleep(80);
|
|
86
|
+
active--;
|
|
87
|
+
handle.release();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await Promise.all([work("alpha"), work("beta"), work("gamma")]);
|
|
91
|
+
expect(maxActive).toBeGreaterThan(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("stale lock (dead pid) is taken over by next acquirer", async () => {
|
|
95
|
+
// Inject _isProcessAlive that says the recorded holder is gone.
|
|
96
|
+
const handle = await acquireTurnLock({
|
|
97
|
+
agentName: "stale",
|
|
98
|
+
overstoryDir,
|
|
99
|
+
ownerPid: 99999, // pretend we are this dead pid
|
|
100
|
+
_isProcessAlive: () => true, // claim alive to plant the lock
|
|
101
|
+
});
|
|
102
|
+
// Don't call release() — we want the row to look orphaned.
|
|
103
|
+
|
|
104
|
+
// Reset in-process locks so the next call is not blocked by the queue
|
|
105
|
+
// from the same Bun process. Cross-process is what we are exercising.
|
|
106
|
+
_resetInProcessLocks();
|
|
107
|
+
|
|
108
|
+
const stolen = await acquireTurnLock({
|
|
109
|
+
agentName: "stale",
|
|
110
|
+
overstoryDir,
|
|
111
|
+
ownerPid: 12345,
|
|
112
|
+
_isProcessAlive: () => false, // prior holder reported dead
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const state = readTurnLock(overstoryDir, "stale");
|
|
116
|
+
expect(state.heldByPid).toBe(12345);
|
|
117
|
+
} finally {
|
|
118
|
+
stolen.release();
|
|
119
|
+
// release() of the original handle would still be safe because the
|
|
120
|
+
// row pid no longer matches its ownerPid (99999).
|
|
121
|
+
handle.release();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("acquire times out when the lock is held by a live foreign pid", async () => {
|
|
126
|
+
// Plant a lock owned by a different live pid (we say always-alive).
|
|
127
|
+
const planted = await acquireTurnLock({
|
|
128
|
+
agentName: "blocked",
|
|
129
|
+
overstoryDir,
|
|
130
|
+
ownerPid: 77777,
|
|
131
|
+
_isProcessAlive: () => true,
|
|
132
|
+
});
|
|
133
|
+
// Intentionally do NOT release planted — keep the row active.
|
|
134
|
+
|
|
135
|
+
_resetInProcessLocks();
|
|
136
|
+
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
await expect(
|
|
139
|
+
acquireTurnLock({
|
|
140
|
+
agentName: "blocked",
|
|
141
|
+
overstoryDir,
|
|
142
|
+
ownerPid: 88888,
|
|
143
|
+
_isProcessAlive: () => true,
|
|
144
|
+
timeoutMs: 200,
|
|
145
|
+
pollMs: 25,
|
|
146
|
+
}),
|
|
147
|
+
).rejects.toThrow(/timed out/);
|
|
148
|
+
const elapsed = Date.now() - start;
|
|
149
|
+
expect(elapsed).toBeGreaterThanOrEqual(150);
|
|
150
|
+
|
|
151
|
+
planted.release();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("re-acquire by the same owner pid is allowed (re-entrant by pid)", async () => {
|
|
155
|
+
// First acquire records pid X. A subsequent acquire by the same pid
|
|
156
|
+
// (after in-process queue clears) should succeed without timing out
|
|
157
|
+
// even if the row still names X — this models recovery from a crashed
|
|
158
|
+
// in-process holder where the SQLite row was never released.
|
|
159
|
+
const first = await acquireTurnLock({
|
|
160
|
+
agentName: "reentry",
|
|
161
|
+
overstoryDir,
|
|
162
|
+
ownerPid: 4242,
|
|
163
|
+
});
|
|
164
|
+
// Simulate an in-process crash that lost the in-process tail.
|
|
165
|
+
_resetInProcessLocks();
|
|
166
|
+
|
|
167
|
+
const second = await acquireTurnLock({
|
|
168
|
+
agentName: "reentry",
|
|
169
|
+
overstoryDir,
|
|
170
|
+
ownerPid: 4242,
|
|
171
|
+
timeoutMs: 500,
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
const state = readTurnLock(overstoryDir, "reentry");
|
|
175
|
+
expect(state.heldByPid).toBe(4242);
|
|
176
|
+
} finally {
|
|
177
|
+
second.release();
|
|
178
|
+
first.release();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|