@os-eco/overstory-cli 0.9.4 → 0.11.0
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 +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- 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 +219 -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/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- 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 +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- 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 +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- 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 +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -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 +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -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/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- 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 +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- 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 +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
|
@@ -15,9 +15,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
15
15
|
import { mkdir, realpath } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { createMailClient } from "../mail/client.ts";
|
|
19
|
+
import { createMailStore } from "../mail/store.ts";
|
|
18
20
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
19
21
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
20
|
-
import type { AgentSession } from "../types.ts";
|
|
22
|
+
import type { AgentSession, MergeReadyPayload } from "../types.ts";
|
|
21
23
|
import { type StopDeps, stopCommand } from "./stop.ts";
|
|
22
24
|
|
|
23
25
|
// --- Fake Git (for branch deletion) ---
|
|
@@ -472,6 +474,258 @@ describe("stopCommand stop behavior", () => {
|
|
|
472
474
|
store.close();
|
|
473
475
|
expect(updated?.state).toBe("completed");
|
|
474
476
|
});
|
|
477
|
+
|
|
478
|
+
test("stopping a lead writes lead_completed pending-nudge for coordinator", async () => {
|
|
479
|
+
// Regression test for overstory-49a7:
|
|
480
|
+
// The lead_completed nudge now fires from `ov stop` (real completion signal),
|
|
481
|
+
// not from the per-turn Stop hook, which was spamming the coordinator.
|
|
482
|
+
const session = makeAgentSession({
|
|
483
|
+
agentName: "lead-alpha",
|
|
484
|
+
capability: "lead",
|
|
485
|
+
state: "working",
|
|
486
|
+
tmuxSession: "overstory-lead-alpha",
|
|
487
|
+
});
|
|
488
|
+
saveSessionsToDb([session]);
|
|
489
|
+
|
|
490
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
491
|
+
await stopCommand("lead-alpha", {}, deps);
|
|
492
|
+
|
|
493
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
494
|
+
const markerFile = Bun.file(markerPath);
|
|
495
|
+
expect(await markerFile.exists()).toBe(true);
|
|
496
|
+
|
|
497
|
+
const marker = JSON.parse(await markerFile.text());
|
|
498
|
+
expect(marker.from).toBe("lead-alpha");
|
|
499
|
+
expect(marker.reason).toBe("lead_completed");
|
|
500
|
+
expect(marker.subject).toContain("lead-alpha");
|
|
501
|
+
expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
|
|
502
|
+
expect(marker.createdAt).toBeDefined();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("lead exiting without merge_ready gets 'no merge_ready sent' subject (overstory-41fe)", async () => {
|
|
506
|
+
const session = makeAgentSession({
|
|
507
|
+
agentName: "lead-beta",
|
|
508
|
+
capability: "lead",
|
|
509
|
+
state: "working",
|
|
510
|
+
tmuxSession: "overstory-lead-beta",
|
|
511
|
+
});
|
|
512
|
+
saveSessionsToDb([session]);
|
|
513
|
+
|
|
514
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
515
|
+
await stopCommand("lead-beta", {}, deps);
|
|
516
|
+
|
|
517
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
518
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
519
|
+
expect(marker.subject).toBe(
|
|
520
|
+
"Lead lead-beta exited — no merge_ready sent, needs coordinator follow-up",
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("lead with one merge_ready gets branch-specific subject (overstory-41fe)", async () => {
|
|
525
|
+
const session = makeAgentSession({
|
|
526
|
+
agentName: "lead-gamma",
|
|
527
|
+
capability: "lead",
|
|
528
|
+
state: "working",
|
|
529
|
+
tmuxSession: "overstory-lead-gamma",
|
|
530
|
+
});
|
|
531
|
+
saveSessionsToDb([session]);
|
|
532
|
+
|
|
533
|
+
// Seed mail.db with a merge_ready message from this lead
|
|
534
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
535
|
+
const mailClient = createMailClient(mailStore);
|
|
536
|
+
const payload: MergeReadyPayload = {
|
|
537
|
+
branch: "overstory/lead-gamma/bead-42",
|
|
538
|
+
taskId: "bead-42",
|
|
539
|
+
agentName: "lead-gamma",
|
|
540
|
+
filesModified: ["src/foo.ts"],
|
|
541
|
+
};
|
|
542
|
+
mailClient.sendProtocol({
|
|
543
|
+
from: "lead-gamma",
|
|
544
|
+
to: "coordinator",
|
|
545
|
+
subject: "merge_ready: bead-42",
|
|
546
|
+
body: "ready",
|
|
547
|
+
type: "merge_ready",
|
|
548
|
+
payload,
|
|
549
|
+
});
|
|
550
|
+
mailClient.close();
|
|
551
|
+
|
|
552
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
553
|
+
await stopCommand("lead-gamma", {}, deps);
|
|
554
|
+
|
|
555
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
556
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
557
|
+
expect(marker.subject).toBe(
|
|
558
|
+
"Lead lead-gamma sent merge_ready for branch overstory/lead-gamma/bead-42",
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("lead with multiple merge_ready messages lists all unique branches (overstory-41fe)", async () => {
|
|
563
|
+
const session = makeAgentSession({
|
|
564
|
+
agentName: "lead-delta",
|
|
565
|
+
capability: "lead",
|
|
566
|
+
state: "working",
|
|
567
|
+
tmuxSession: "overstory-lead-delta",
|
|
568
|
+
});
|
|
569
|
+
saveSessionsToDb([session]);
|
|
570
|
+
|
|
571
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
572
|
+
const mailClient = createMailClient(mailStore);
|
|
573
|
+
for (const branch of ["overstory/worker-a/t1", "overstory/worker-b/t2"]) {
|
|
574
|
+
mailClient.sendProtocol({
|
|
575
|
+
from: "lead-delta",
|
|
576
|
+
to: "coordinator",
|
|
577
|
+
subject: `merge_ready: ${branch}`,
|
|
578
|
+
body: "ready",
|
|
579
|
+
type: "merge_ready",
|
|
580
|
+
payload: {
|
|
581
|
+
branch,
|
|
582
|
+
taskId: branch.split("/")[2] ?? "unknown",
|
|
583
|
+
agentName: "lead-delta",
|
|
584
|
+
filesModified: [],
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
mailClient.close();
|
|
589
|
+
|
|
590
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
591
|
+
await stopCommand("lead-delta", {}, deps);
|
|
592
|
+
|
|
593
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
594
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
595
|
+
expect(marker.subject).toContain("Lead lead-delta sent 2 merge_ready");
|
|
596
|
+
expect(marker.subject).toContain("overstory/worker-a/t1");
|
|
597
|
+
expect(marker.subject).toContain("overstory/worker-b/t2");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("merge_ready messages from other agents do not influence the subject (overstory-41fe)", async () => {
|
|
601
|
+
const session = makeAgentSession({
|
|
602
|
+
agentName: "lead-eps",
|
|
603
|
+
capability: "lead",
|
|
604
|
+
state: "working",
|
|
605
|
+
tmuxSession: "overstory-lead-eps",
|
|
606
|
+
});
|
|
607
|
+
saveSessionsToDb([session]);
|
|
608
|
+
|
|
609
|
+
// A *different* lead has merge_ready in the same mail.db — should be ignored
|
|
610
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
611
|
+
const mailClient = createMailClient(mailStore);
|
|
612
|
+
mailClient.sendProtocol({
|
|
613
|
+
from: "some-other-lead",
|
|
614
|
+
to: "coordinator",
|
|
615
|
+
subject: "merge_ready: x",
|
|
616
|
+
body: "ready",
|
|
617
|
+
type: "merge_ready",
|
|
618
|
+
payload: {
|
|
619
|
+
branch: "overstory/other/x",
|
|
620
|
+
taskId: "x",
|
|
621
|
+
agentName: "some-other-lead",
|
|
622
|
+
filesModified: [],
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
mailClient.close();
|
|
626
|
+
|
|
627
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
628
|
+
await stopCommand("lead-eps", {}, deps);
|
|
629
|
+
|
|
630
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
631
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
632
|
+
expect(marker.subject).toBe(
|
|
633
|
+
"Lead lead-eps exited — no merge_ready sent, needs coordinator follow-up",
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("lead falls back to historical subject when mail store cannot be opened (overstory-7291)", async () => {
|
|
638
|
+
const session = makeAgentSession({
|
|
639
|
+
agentName: "lead-zeta",
|
|
640
|
+
capability: "lead",
|
|
641
|
+
state: "working",
|
|
642
|
+
tmuxSession: "overstory-lead-zeta",
|
|
643
|
+
});
|
|
644
|
+
saveSessionsToDb([session]);
|
|
645
|
+
|
|
646
|
+
// Make mail.db un-openable by creating a directory at that path. SQLite
|
|
647
|
+
// cannot open a directory as a database, so createMailStore() throws and
|
|
648
|
+
// buildLeadCompletedSubject hits its outer-catch fallback.
|
|
649
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
650
|
+
await mkdir(mailDbPath, { recursive: true });
|
|
651
|
+
|
|
652
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
653
|
+
await stopCommand("lead-zeta", {}, deps);
|
|
654
|
+
|
|
655
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
656
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
657
|
+
expect(marker.subject).toBe(
|
|
658
|
+
"Lead lead-zeta completed — check mail for merge_ready/worker_done",
|
|
659
|
+
);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("lead with malformed merge_ready payload skips that message (overstory-7291)", async () => {
|
|
663
|
+
const session = makeAgentSession({
|
|
664
|
+
agentName: "lead-eta",
|
|
665
|
+
capability: "lead",
|
|
666
|
+
state: "working",
|
|
667
|
+
tmuxSession: "overstory-lead-eta",
|
|
668
|
+
});
|
|
669
|
+
saveSessionsToDb([session]);
|
|
670
|
+
|
|
671
|
+
// Insert two merge_ready rows directly via the store: one with a valid
|
|
672
|
+
// MergeReadyPayload, one with a non-JSON payload string. sendProtocol
|
|
673
|
+
// would JSON.stringify any payload, so it cannot produce a malformed
|
|
674
|
+
// row — the low-level store accepts the payload column verbatim. The
|
|
675
|
+
// loop must skip the malformed one (inner catch + continue) and use
|
|
676
|
+
// the valid one, yielding the single-branch subject variant.
|
|
677
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
678
|
+
const validPayload: MergeReadyPayload = {
|
|
679
|
+
branch: "overstory/lead-eta/bead-99",
|
|
680
|
+
taskId: "bead-99",
|
|
681
|
+
agentName: "lead-eta",
|
|
682
|
+
filesModified: ["src/x.ts"],
|
|
683
|
+
};
|
|
684
|
+
mailStore.insert({
|
|
685
|
+
id: "msg-valid",
|
|
686
|
+
from: "lead-eta",
|
|
687
|
+
to: "coordinator",
|
|
688
|
+
subject: "merge_ready: bead-99",
|
|
689
|
+
body: "ready",
|
|
690
|
+
type: "merge_ready",
|
|
691
|
+
priority: "normal",
|
|
692
|
+
threadId: null,
|
|
693
|
+
payload: JSON.stringify(validPayload),
|
|
694
|
+
});
|
|
695
|
+
mailStore.insert({
|
|
696
|
+
id: "msg-malformed",
|
|
697
|
+
from: "lead-eta",
|
|
698
|
+
to: "coordinator",
|
|
699
|
+
subject: "merge_ready: broken",
|
|
700
|
+
body: "ready",
|
|
701
|
+
type: "merge_ready",
|
|
702
|
+
priority: "normal",
|
|
703
|
+
threadId: null,
|
|
704
|
+
payload: "not-json{",
|
|
705
|
+
});
|
|
706
|
+
mailStore.close();
|
|
707
|
+
|
|
708
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
709
|
+
await stopCommand("lead-eta", {}, deps);
|
|
710
|
+
|
|
711
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
712
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
713
|
+
expect(marker.subject).toBe(
|
|
714
|
+
"Lead lead-eta sent merge_ready for branch overstory/lead-eta/bead-99",
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("stopping a non-lead agent does NOT write lead_completed pending-nudge", async () => {
|
|
719
|
+
const session = makeAgentSession({ state: "working", capability: "builder" });
|
|
720
|
+
saveSessionsToDb([session]);
|
|
721
|
+
|
|
722
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
723
|
+
await stopCommand("my-builder", {}, deps);
|
|
724
|
+
|
|
725
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
726
|
+
const markerFile = Bun.file(markerPath);
|
|
727
|
+
expect(await markerFile.exists()).toBe(false);
|
|
728
|
+
});
|
|
475
729
|
});
|
|
476
730
|
|
|
477
731
|
describe("stopCommand --json output", () => {
|
package/src/commands/stop.ts
CHANGED
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
* With --clean-worktree, completed agents skip the kill step and proceed to cleanup.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { unlink } from "node:fs/promises";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { loadConfig } from "../config.ts";
|
|
17
18
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
19
|
import { jsonOutput } from "../json.ts";
|
|
19
20
|
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
21
|
+
import { createMailStore } from "../mail/store.ts";
|
|
20
22
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
23
|
+
import type { MergeReadyPayload } from "../types.ts";
|
|
24
|
+
import { readPidFile } from "../utils/pid.ts";
|
|
21
25
|
import { removeWorktree } from "../worktree/manager.ts";
|
|
22
26
|
import { isProcessAlive, isSessionAlive, killProcessTree, killSession } from "../worktree/tmux.ts";
|
|
23
27
|
|
|
@@ -49,6 +53,56 @@ export interface StopDeps {
|
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Build the lead_completed nudge subject based on whether the lead actually sent
|
|
58
|
+
* merge_ready before exiting (overstory-41fe). The merge_ready close-gate
|
|
59
|
+
* (commit 3e21338) prevents leads from running `sd close` without it, but a
|
|
60
|
+
* lead can still exit (process termination, watchdog kill, manual `ov stop`)
|
|
61
|
+
* without ever having sent one. The coordinator's surfacing of this nudge
|
|
62
|
+
* needs to distinguish those two cases.
|
|
63
|
+
*/
|
|
64
|
+
function buildLeadCompletedSubject(agentName: string, mailDbPath: string): string {
|
|
65
|
+
let mergeReadyBranches: string[] = [];
|
|
66
|
+
let mergeReadyCount = 0;
|
|
67
|
+
try {
|
|
68
|
+
const store = createMailStore(mailDbPath);
|
|
69
|
+
try {
|
|
70
|
+
const messages = store.getAll({ from: agentName, type: "merge_ready" });
|
|
71
|
+
mergeReadyCount = messages.length;
|
|
72
|
+
for (const msg of messages) {
|
|
73
|
+
if (msg.payload === null) continue;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(msg.payload) as Partial<MergeReadyPayload>;
|
|
76
|
+
if (typeof parsed.branch === "string" && parsed.branch.length > 0) {
|
|
77
|
+
mergeReadyBranches.push(parsed.branch);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Skip messages with unparseable payloads
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
store.close();
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// If the mail store can't be opened (corrupt db, permissions), fall back
|
|
88
|
+
// to the historical ambiguous phrasing rather than blocking the stop.
|
|
89
|
+
return `Lead ${agentName} completed — check mail for merge_ready/worker_done`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (mergeReadyCount === 0) {
|
|
93
|
+
return `Lead ${agentName} exited — no merge_ready sent, needs coordinator follow-up`;
|
|
94
|
+
}
|
|
95
|
+
// Dedupe in case a lead resent merge_ready for the same branch
|
|
96
|
+
mergeReadyBranches = Array.from(new Set(mergeReadyBranches));
|
|
97
|
+
if (mergeReadyBranches.length === 0) {
|
|
98
|
+
return `Lead ${agentName} sent ${mergeReadyCount} merge_ready (branch unknown)`;
|
|
99
|
+
}
|
|
100
|
+
if (mergeReadyBranches.length === 1) {
|
|
101
|
+
return `Lead ${agentName} sent merge_ready for branch ${mergeReadyBranches[0]}`;
|
|
102
|
+
}
|
|
103
|
+
return `Lead ${agentName} sent ${mergeReadyBranches.length} merge_ready (branches: ${mergeReadyBranches.join(", ")})`;
|
|
104
|
+
}
|
|
105
|
+
|
|
52
106
|
/** Delete a git branch (best-effort, non-fatal). */
|
|
53
107
|
async function deleteBranchBestEffort(repoRoot: string, branch: string): Promise<boolean> {
|
|
54
108
|
try {
|
|
@@ -115,20 +169,35 @@ export async function stopCommand(
|
|
|
115
169
|
}
|
|
116
170
|
|
|
117
171
|
const isZombie = session.state === "zombie";
|
|
118
|
-
|
|
172
|
+
// Headless task-scoped agents (Phase 3 spawn-per-turn): tmuxSession is ""
|
|
173
|
+
// and session.pid is null between turns. The live PID for an in-flight
|
|
174
|
+
// turn is published at .overstory/agents/<name>/turn.pid. Sapling RPC
|
|
175
|
+
// agents still use session.pid for their long-lived process.
|
|
176
|
+
const isHeadless = session.tmuxSession === "";
|
|
177
|
+
const turnPidPath = join(overstoryDir, "agents", agentName, "turn.pid");
|
|
119
178
|
|
|
120
179
|
let tmuxKilled = false;
|
|
121
180
|
let pidKilled = false;
|
|
122
181
|
|
|
123
182
|
// Skip kill operations for already-completed agents (process/tmux already gone)
|
|
124
183
|
if (!isAlreadyCompleted) {
|
|
125
|
-
if (isHeadless
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
if (isHeadless) {
|
|
185
|
+
// Prefer the per-turn PID file (Phase 3) — this catches an in-flight
|
|
186
|
+
// claude turn for any task-scoped capability. Fall back to the
|
|
187
|
+
// session row's pid for legacy/long-lived headless runtimes (Sapling).
|
|
188
|
+
const turnPid = await readPidFile(turnPidPath);
|
|
189
|
+
const targetPid = turnPid ?? session.pid;
|
|
190
|
+
if (targetPid !== null && proc.isAlive(targetPid)) {
|
|
191
|
+
await proc.killTree(targetPid);
|
|
130
192
|
pidKilled = true;
|
|
131
193
|
}
|
|
194
|
+
// Reap the turn.pid file so a subsequent ov stop / mail injector
|
|
195
|
+
// doesn't see a stale entry. Idempotent.
|
|
196
|
+
try {
|
|
197
|
+
await unlink(turnPidPath);
|
|
198
|
+
} catch {
|
|
199
|
+
// already gone — non-fatal
|
|
200
|
+
}
|
|
132
201
|
} else {
|
|
133
202
|
// TUI agent: kill via tmux session
|
|
134
203
|
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
@@ -138,9 +207,39 @@ export async function stopCommand(
|
|
|
138
207
|
}
|
|
139
208
|
}
|
|
140
209
|
|
|
141
|
-
// Mark session as completed
|
|
142
|
-
|
|
210
|
+
// Mark session as completed via the guarded transition. `completed` is
|
|
211
|
+
// reachable from every non-completed state (including zombie, so `ov
|
|
212
|
+
// stop` can promote a watchdog-flagged zombie to a clean completion),
|
|
213
|
+
// so the only way this rejects is if state is already `completed` —
|
|
214
|
+
// which is the no-op we want anyway. Race-safe under overstory-a993.
|
|
215
|
+
store.tryTransitionState(agentName, "completed");
|
|
143
216
|
store.updateLastActivity(agentName);
|
|
217
|
+
|
|
218
|
+
// Auto-nudge coordinator when a lead truly completes so it wakes up
|
|
219
|
+
// to process merge_ready / worker_done messages without waiting for
|
|
220
|
+
// user input. Fires from `ov stop` (real completion signal) rather
|
|
221
|
+
// than the per-turn Stop hook, which was spamming the coordinator
|
|
222
|
+
// (overstory-49a7).
|
|
223
|
+
if (session.capability === "lead") {
|
|
224
|
+
try {
|
|
225
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
226
|
+
const subject = buildLeadCompletedSubject(agentName, mailDbPath);
|
|
227
|
+
const nudgesDir = join(overstoryDir, "pending-nudges");
|
|
228
|
+
const { mkdir } = await import("node:fs/promises");
|
|
229
|
+
await mkdir(nudgesDir, { recursive: true });
|
|
230
|
+
const markerPath = join(nudgesDir, "coordinator.json");
|
|
231
|
+
const marker = {
|
|
232
|
+
from: agentName,
|
|
233
|
+
reason: "lead_completed",
|
|
234
|
+
subject,
|
|
235
|
+
messageId: `auto-nudge-${agentName}-${Date.now()}`,
|
|
236
|
+
createdAt: new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
|
|
239
|
+
} catch {
|
|
240
|
+
// Non-fatal: nudge failure should not break stop
|
|
241
|
+
}
|
|
242
|
+
}
|
|
144
243
|
}
|
|
145
244
|
|
|
146
245
|
// Optionally remove worktree and branch (best-effort, non-fatal)
|
|
@@ -88,6 +88,7 @@ describe("watchCommand", () => {
|
|
|
88
88
|
expect(out).toContain("watch");
|
|
89
89
|
expect(out).toContain("--interval");
|
|
90
90
|
expect(out).toContain("--background");
|
|
91
|
+
expect(out).toContain("--kill-others");
|
|
91
92
|
expect(out).toContain("Tier 0");
|
|
92
93
|
});
|
|
93
94
|
|
|
@@ -113,6 +114,48 @@ describe("watchCommand", () => {
|
|
|
113
114
|
expect(process.exitCode).toBe(1);
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
test("foreground mode: refuses when a live foreign PID owns the lock", async () => {
|
|
118
|
+
// Spawn a long-running child to act as the "foreign live process". Its
|
|
119
|
+
// PID will not match our own, so acquirePidLock should refuse rather
|
|
120
|
+
// than treat the existing PID as idempotent self-ownership. The
|
|
121
|
+
// foreground path used to overwrite this file unconditionally — the
|
|
122
|
+
// overstory-8ef6 fix forces it to refuse.
|
|
123
|
+
const sleeper = Bun.spawn(["sleep", "30"], {
|
|
124
|
+
stdout: "ignore",
|
|
125
|
+
stderr: "ignore",
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");
|
|
129
|
+
await Bun.write(pidFilePath, `${sleeper.pid}\n`);
|
|
130
|
+
|
|
131
|
+
// --json for structured output. No --background, so this exercises
|
|
132
|
+
// the foreground exclusion path. A correctly contested lock returns
|
|
133
|
+
// immediately (exit 1) without starting the daemon loop.
|
|
134
|
+
await watchCommand(["--json"]);
|
|
135
|
+
|
|
136
|
+
const out = output();
|
|
137
|
+
const jsonLine = out
|
|
138
|
+
.split("\n")
|
|
139
|
+
.map((l) => l.trim())
|
|
140
|
+
.find((l) => l.startsWith("{"));
|
|
141
|
+
expect(jsonLine).toBeDefined();
|
|
142
|
+
if (jsonLine) {
|
|
143
|
+
const parsed = JSON.parse(jsonLine);
|
|
144
|
+
expect(parsed.running).toBe(true);
|
|
145
|
+
expect(parsed.pid).toBe(sleeper.pid);
|
|
146
|
+
expect(parsed.error).toContain("already running");
|
|
147
|
+
}
|
|
148
|
+
expect(process.exitCode).toBe(1);
|
|
149
|
+
|
|
150
|
+
// PID file untouched — still the foreign owner's PID.
|
|
151
|
+
const fileContent = await Bun.file(pidFilePath).text();
|
|
152
|
+
expect(fileContent.trim()).toBe(`${sleeper.pid}`);
|
|
153
|
+
} finally {
|
|
154
|
+
sleeper.kill("SIGTERM");
|
|
155
|
+
await sleeper.exited.catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
116
159
|
test("background mode: stale PID cleanup", async () => {
|
|
117
160
|
// Write a PID file with a non-running process (999999 is very unlikely to exist)
|
|
118
161
|
const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");
|