@os-eco/overstory-cli 0.10.3 → 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 +4 -2
- package/agents/builder.md +10 -1
- package/agents/lead.md +106 -5
- package/package.json +1 -1
- package/src/agents/headless-mail-injector.ts +8 -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 +56 -0
- package/src/agents/overlay.ts +33 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-runner.test.ts +862 -0
- package/src/agents/turn-runner.ts +225 -8
- package/src/commands/agents.ts +9 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +71 -4
- package/src/commands/dashboard.ts +1 -1
- package/src/commands/log.test.ts +131 -0
- package/src/commands/log.ts +37 -2
- package/src/commands/merge.test.ts +118 -0
- package/src/commands/merge.ts +51 -8
- package/src/commands/sling.test.ts +104 -0
- package/src/commands/sling.ts +95 -8
- package/src/commands/stop.test.ts +81 -0
- package/src/index.ts +5 -1
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/logging/theme.ts +4 -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/sessions/store.test.ts +267 -5
- package/src/sessions/store.ts +105 -7
- package/src/types.ts +51 -1
- package/src/watchdog/daemon.test.ts +124 -2
- package/src/watchdog/daemon.ts +27 -12
- package/src/watchdog/health.test.ts +133 -8
- package/src/watchdog/health.ts +37 -5
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/tmux.test.ts +25 -0
- package/src/worktree/tmux.ts +17 -0
- package/templates/overlay.md.tmpl +2 -0
|
@@ -133,11 +133,17 @@ describe("upsert", () => {
|
|
|
133
133
|
expect(result?.stalledSince).toBeNull();
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test("
|
|
136
|
+
test("accepts arbitrary state strings (CHECK relaxed in overstory-3087)", () => {
|
|
137
|
+
// The inline CHECK on `state` was dropped (overstory-3087): the
|
|
138
|
+
// TypeScript `AgentState` union enforces values at the writer
|
|
139
|
+
// boundary, so the SQL constraint became a maintenance tax that had
|
|
140
|
+
// to be rebuilt on every union extension. Verify the schema no
|
|
141
|
+
// longer throws when an out-of-union value reaches it. Real callers
|
|
142
|
+
// type their writes through the union and cannot land here.
|
|
137
143
|
const session = makeSession();
|
|
138
|
-
// Force an invalid state to test the CHECK constraint
|
|
139
144
|
const badSession = { ...session, state: "invalid" as AgentState };
|
|
140
|
-
expect(() => store.upsert(badSession)).toThrow();
|
|
145
|
+
expect(() => store.upsert(badSession)).not.toThrow();
|
|
146
|
+
expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
|
|
141
147
|
});
|
|
142
148
|
|
|
143
149
|
test("handles null transcriptPath", () => {
|
|
@@ -376,9 +382,13 @@ describe("updateState", () => {
|
|
|
376
382
|
store.updateState("nonexistent", "completed");
|
|
377
383
|
});
|
|
378
384
|
|
|
379
|
-
test("
|
|
385
|
+
test("accepts arbitrary state strings (CHECK relaxed in overstory-3087)", () => {
|
|
386
|
+
// Same rationale as the upsert test: the TypeScript `AgentState`
|
|
387
|
+
// union is the authoritative gate; the SQL CHECK was dropped to
|
|
388
|
+
// avoid the rebuild tax on every union extension.
|
|
380
389
|
store.upsert(makeSession());
|
|
381
|
-
expect(() => store.updateState("test-agent", "invalid" as AgentState)).toThrow();
|
|
390
|
+
expect(() => store.updateState("test-agent", "invalid" as AgentState)).not.toThrow();
|
|
391
|
+
expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
|
|
382
392
|
});
|
|
383
393
|
});
|
|
384
394
|
|
|
@@ -572,6 +582,258 @@ describe("tryTransitionState", () => {
|
|
|
572
582
|
});
|
|
573
583
|
});
|
|
574
584
|
|
|
585
|
+
// === in_turn / between_turns spawn-per-turn substates (overstory-3087) ===
|
|
586
|
+
//
|
|
587
|
+
// The spawn-per-turn engine splits the legacy `working` state into two:
|
|
588
|
+
// `in_turn` (claude is mid-execution, parser events streaming) and
|
|
589
|
+
// `between_turns` (claude exited cleanly, agent waiting for the next mail
|
|
590
|
+
// batch). The matrix must allow the cycle in both directions and forward
|
|
591
|
+
// progression to terminal/error states from either substate. The CHECK
|
|
592
|
+
// constraint and `getActive` query must accept the new values so the
|
|
593
|
+
// watchdog and dashboards see these workers as alive.
|
|
594
|
+
|
|
595
|
+
describe("in_turn / between_turns substates", () => {
|
|
596
|
+
test("upsert accepts in_turn via CHECK constraint", () => {
|
|
597
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
598
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("upsert accepts between_turns via CHECK constraint", () => {
|
|
602
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
603
|
+
expect(store.getByName("test-agent")?.state).toBe("between_turns");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("getActive includes in_turn and between_turns alongside working/booting/stalled", () => {
|
|
607
|
+
store.upsert(makeSession({ agentName: "a-it", id: "s-it", state: "in_turn" }));
|
|
608
|
+
store.upsert(makeSession({ agentName: "a-bt", id: "s-bt", state: "between_turns" }));
|
|
609
|
+
store.upsert(makeSession({ agentName: "a-w", id: "s-w", state: "working" }));
|
|
610
|
+
store.upsert(makeSession({ agentName: "a-c", id: "s-c", state: "completed" }));
|
|
611
|
+
store.upsert(makeSession({ agentName: "a-z", id: "s-z", state: "zombie" }));
|
|
612
|
+
|
|
613
|
+
const activeNames = store
|
|
614
|
+
.getActive()
|
|
615
|
+
.map((s) => s.agentName)
|
|
616
|
+
.sort();
|
|
617
|
+
expect(activeNames).toEqual(["a-bt", "a-it", "a-w"]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("booting → in_turn lands (turn-runner first-event transition)", () => {
|
|
621
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
622
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
623
|
+
expect(outcome.ok).toBe(true);
|
|
624
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("in_turn → between_turns lands (turn-runner end-of-turn settle)", () => {
|
|
628
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
629
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
630
|
+
expect(outcome.ok).toBe(true);
|
|
631
|
+
expect(store.getByName("test-agent")?.state).toBe("between_turns");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("between_turns → in_turn lands (next mail batch starts a turn)", () => {
|
|
635
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
636
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
637
|
+
expect(outcome.ok).toBe(true);
|
|
638
|
+
expect(store.getByName("test-agent")?.state).toBe("in_turn");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("legacy working → in_turn is rejected (spawn-per-turn keeps separate path)", () => {
|
|
642
|
+
// A row in the legacy `working` state predates the spawn-per-turn
|
|
643
|
+
// substate split. The matrix intentionally does not list `working` as
|
|
644
|
+
// a predecessor of `in_turn` — turn-runner.ts handles legacy `working`
|
|
645
|
+
// rows by writing in_turn directly via updateState() / unconditional
|
|
646
|
+
// override on the first parser event of a fresh batch. A
|
|
647
|
+
// CAS-guarded transition is rejected to keep the two paths disjoint.
|
|
648
|
+
store.upsert(makeSession({ state: "working" }));
|
|
649
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
650
|
+
expect(outcome.ok).toBe(false);
|
|
651
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
652
|
+
expect(outcome.prev).toBe("working");
|
|
653
|
+
}
|
|
654
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("legacy working → between_turns is rejected", () => {
|
|
658
|
+
store.upsert(makeSession({ state: "working" }));
|
|
659
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
660
|
+
expect(outcome.ok).toBe(false);
|
|
661
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
662
|
+
expect(outcome.prev).toBe("working");
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("booting → between_turns is rejected (must pass through in_turn)", () => {
|
|
667
|
+
// Spec: between_turns predecessors are in_turn / between_turns /
|
|
668
|
+
// stalled. The agent only reaches between_turns after a turn produced
|
|
669
|
+
// events — which means the turn-runner must have transitioned to
|
|
670
|
+
// in_turn first.
|
|
671
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
672
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
673
|
+
expect(outcome.ok).toBe(false);
|
|
674
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
675
|
+
expect(outcome.prev).toBe("booting");
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("in_turn → completed lands (clean exit + terminal mail)", () => {
|
|
680
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
681
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
682
|
+
expect(outcome.ok).toBe(true);
|
|
683
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("between_turns → completed lands (operator stops an idle worker)", () => {
|
|
687
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
688
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
689
|
+
expect(outcome.ok).toBe(true);
|
|
690
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("in_turn → zombie lands (parser stall / abort)", () => {
|
|
694
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
695
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
696
|
+
expect(outcome.ok).toBe(true);
|
|
697
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("between_turns → zombie lands (watchdog terminate after long idle)", () => {
|
|
701
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
702
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
703
|
+
expect(outcome.ok).toBe(true);
|
|
704
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("in_turn → stalled lands (watchdog escalate)", () => {
|
|
708
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
709
|
+
const outcome = store.tryTransitionState("test-agent", "stalled");
|
|
710
|
+
expect(outcome.ok).toBe(true);
|
|
711
|
+
expect(store.getByName("test-agent")?.state).toBe("stalled");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("idempotent in_turn → in_turn is allowed (re-entering on same batch)", () => {
|
|
715
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
716
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
717
|
+
expect(outcome.ok).toBe(true);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("idempotent between_turns → between_turns is allowed", () => {
|
|
721
|
+
store.upsert(makeSession({ state: "between_turns" }));
|
|
722
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
723
|
+
expect(outcome.ok).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("completed → in_turn is rejected (sticky completed)", () => {
|
|
727
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
728
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
729
|
+
expect(outcome.ok).toBe(false);
|
|
730
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("zombie → in_turn is rejected (turn-runner cannot revive zombie)", () => {
|
|
734
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
735
|
+
const outcome = store.tryTransitionState("test-agent", "in_turn");
|
|
736
|
+
expect(outcome.ok).toBe(false);
|
|
737
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("zombie → between_turns is rejected", () => {
|
|
741
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
742
|
+
const outcome = store.tryTransitionState("test-agent", "between_turns");
|
|
743
|
+
expect(outcome.ok).toBe(false);
|
|
744
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("nothing transitions into booting from in_turn or between_turns", () => {
|
|
748
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
749
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
750
|
+
expect(outcome.ok).toBe(false);
|
|
751
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
752
|
+
expect(outcome.prev).toBe("in_turn");
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// === migration: pre-3087 CHECK constraint relaxation ===
|
|
758
|
+
//
|
|
759
|
+
// SQLite cannot DROP a CHECK constraint in place, so the old inline CHECK on
|
|
760
|
+
// the `state` column must be removed by rebuilding the table. The migration
|
|
761
|
+
// must preserve every existing row verbatim and let inserts of the new
|
|
762
|
+
// values (and any future state extensions) land without a schema bump.
|
|
763
|
+
|
|
764
|
+
describe("migration: drop legacy state CHECK constraint", () => {
|
|
765
|
+
test("rebuilds the table when the recorded CHECK predates 3087", async () => {
|
|
766
|
+
store.close();
|
|
767
|
+
|
|
768
|
+
const { Database: Db } = await import("bun:sqlite");
|
|
769
|
+
const legacyDb = new Db(dbPath);
|
|
770
|
+
legacyDb.exec("DROP TABLE IF EXISTS sessions");
|
|
771
|
+
// Recreate using the pre-3087 CHECK so the migration has something
|
|
772
|
+
// to detect and rebuild.
|
|
773
|
+
legacyDb.exec(`
|
|
774
|
+
CREATE TABLE sessions (
|
|
775
|
+
id TEXT PRIMARY KEY,
|
|
776
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
777
|
+
capability TEXT NOT NULL,
|
|
778
|
+
worktree_path TEXT NOT NULL,
|
|
779
|
+
branch_name TEXT NOT NULL,
|
|
780
|
+
task_id TEXT NOT NULL,
|
|
781
|
+
tmux_session TEXT NOT NULL,
|
|
782
|
+
state TEXT NOT NULL DEFAULT 'booting'
|
|
783
|
+
CHECK(state IN ('booting','working','completed','stalled','zombie')),
|
|
784
|
+
pid INTEGER,
|
|
785
|
+
parent_agent TEXT,
|
|
786
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
787
|
+
run_id TEXT,
|
|
788
|
+
started_at TEXT NOT NULL,
|
|
789
|
+
last_activity TEXT NOT NULL,
|
|
790
|
+
escalation_level INTEGER NOT NULL DEFAULT 0,
|
|
791
|
+
stalled_since TEXT,
|
|
792
|
+
transcript_path TEXT,
|
|
793
|
+
prompt_version TEXT,
|
|
794
|
+
claude_session_id TEXT
|
|
795
|
+
)
|
|
796
|
+
`);
|
|
797
|
+
legacyDb.exec(`
|
|
798
|
+
INSERT INTO sessions
|
|
799
|
+
(id, agent_name, capability, worktree_path, branch_name, task_id,
|
|
800
|
+
tmux_session, state, started_at, last_activity)
|
|
801
|
+
VALUES
|
|
802
|
+
('legacy-1','legacy-agent','builder','/tmp/wt','branch','task',
|
|
803
|
+
'','working','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')
|
|
804
|
+
`);
|
|
805
|
+
legacyDb.close();
|
|
806
|
+
|
|
807
|
+
// Opening a new SessionStore must run the migration and accept new states.
|
|
808
|
+
const migrated = createSessionStore(dbPath);
|
|
809
|
+
try {
|
|
810
|
+
expect(migrated.getByName("legacy-agent")?.state).toBe("working");
|
|
811
|
+
migrated.upsert(makeSession({ agentName: "fresh-it", id: "s-it", state: "in_turn" }));
|
|
812
|
+
migrated.upsert(makeSession({ agentName: "fresh-bt", id: "s-bt", state: "between_turns" }));
|
|
813
|
+
expect(migrated.getByName("fresh-it")?.state).toBe("in_turn");
|
|
814
|
+
expect(migrated.getByName("fresh-bt")?.state).toBe("between_turns");
|
|
815
|
+
} finally {
|
|
816
|
+
migrated.close();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
store = createSessionStore(join(tempDir, "unused.db"));
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("is a no-op when the CHECK has already been dropped (idempotent)", () => {
|
|
823
|
+
// `store` was created by beforeEach against a fresh DB whose CREATE
|
|
824
|
+
// TABLE no longer carries a CHECK on `state`. Reopen on the same path
|
|
825
|
+
// and verify it does not throw or rebuild gratuitously.
|
|
826
|
+
store.upsert(makeSession({ state: "in_turn" }));
|
|
827
|
+
|
|
828
|
+
const reopened = createSessionStore(dbPath);
|
|
829
|
+
try {
|
|
830
|
+
expect(reopened.getByName("test-agent")?.state).toBe("in_turn");
|
|
831
|
+
} finally {
|
|
832
|
+
reopened.close();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
575
837
|
// === tmux_session clearing on terminal transitions (overstory-14c0) ===
|
|
576
838
|
//
|
|
577
839
|
// The tmux session is torn down by ov stop / watchdog / coordinator cleanup
|
package/src/sessions/store.ts
CHANGED
|
@@ -29,15 +29,25 @@ import type {
|
|
|
29
29
|
* wrote zombie is rejected — last writer no longer wins.
|
|
30
30
|
* - Idempotent self-transitions (e.g. `working → working`) are allowed.
|
|
31
31
|
* - `booting` is set only by the initial `upsert` and never re-entered.
|
|
32
|
+
* - `in_turn` and `between_turns` cycle while a spawn-per-turn worker is
|
|
33
|
+
* alive (overstory-3087): turn-runner advances `between_turns → in_turn`
|
|
34
|
+
* when the next batch produces its first parser event and settles back
|
|
35
|
+
* `in_turn → between_turns` when the turn ends without a terminal mail.
|
|
36
|
+
* Both can advance forward to `stalled`/`zombie`/`completed`. The two
|
|
37
|
+
* paths are kept separate from the tmux/long-lived `working` rank — a
|
|
38
|
+
* spawn-per-turn worker should not flow through `working` during normal
|
|
39
|
+
* operation — so neither lists `working` as a predecessor.
|
|
32
40
|
*
|
|
33
41
|
* See overstory-a993 for the race symptoms this guard prevents.
|
|
34
42
|
*/
|
|
35
43
|
const TRANSITION_ALLOWED_FROM: Record<AgentState, readonly AgentState[]> = {
|
|
36
44
|
booting: [],
|
|
37
45
|
working: ["booting", "working", "stalled"],
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
in_turn: ["booting", "in_turn", "between_turns", "stalled"],
|
|
47
|
+
between_turns: ["in_turn", "between_turns", "stalled"],
|
|
48
|
+
stalled: ["booting", "working", "in_turn", "between_turns", "stalled"],
|
|
49
|
+
completed: ["booting", "working", "in_turn", "between_turns", "stalled", "zombie", "completed"],
|
|
50
|
+
zombie: ["booting", "working", "in_turn", "between_turns", "stalled", "zombie"],
|
|
41
51
|
};
|
|
42
52
|
|
|
43
53
|
/**
|
|
@@ -57,7 +67,14 @@ export interface SessionStore {
|
|
|
57
67
|
upsert(session: AgentSession): void;
|
|
58
68
|
/** Get a session by agent name, or null if not found. */
|
|
59
69
|
getByName(agentName: string): AgentSession | null;
|
|
60
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Get all active sessions (state IN ('booting', 'working', 'in_turn',
|
|
72
|
+
* 'between_turns', 'stalled')).
|
|
73
|
+
*
|
|
74
|
+
* `in_turn` and `between_turns` are spawn-per-turn equivalents of `working`
|
|
75
|
+
* and must be returned by `getActive` so the watchdog and dashboards see
|
|
76
|
+
* spawn-per-turn workers as alive (overstory-3087).
|
|
77
|
+
*/
|
|
61
78
|
getActive(): AgentSession[];
|
|
62
79
|
/** Get all sessions regardless of state. */
|
|
63
80
|
getAll(): AgentSession[];
|
|
@@ -142,8 +159,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
142
159
|
branch_name TEXT NOT NULL,
|
|
143
160
|
task_id TEXT NOT NULL,
|
|
144
161
|
tmux_session TEXT NOT NULL,
|
|
145
|
-
state TEXT NOT NULL DEFAULT 'booting'
|
|
146
|
-
CHECK(state IN ('booting','working','completed','stalled','zombie')),
|
|
162
|
+
state TEXT NOT NULL DEFAULT 'booting',
|
|
147
163
|
pid INTEGER,
|
|
148
164
|
parent_agent TEXT,
|
|
149
165
|
depth INTEGER NOT NULL DEFAULT 0,
|
|
@@ -251,6 +267,83 @@ function migrateAddClaudeSessionId(db: Database): void {
|
|
|
251
267
|
}
|
|
252
268
|
}
|
|
253
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Drop the inline CHECK(state IN (...)) constraint from the sessions table
|
|
272
|
+
* (overstory-3087).
|
|
273
|
+
*
|
|
274
|
+
* The CHECK was defensive — the TypeScript `AgentState` union enforces values
|
|
275
|
+
* at the writer boundary. With the spawn-per-turn substate split (`in_turn` /
|
|
276
|
+
* `between_turns`) and likely future state extensions, keeping the constraint
|
|
277
|
+
* in sync with the union via inline-CHECK rebuilds becomes a recurring tax.
|
|
278
|
+
* Drop it and rely on the type system.
|
|
279
|
+
*
|
|
280
|
+
* SQLite has no `ALTER TABLE DROP CONSTRAINT`, so we detect the old constraint
|
|
281
|
+
* via `sqlite_master.sql` (the recorded CREATE TABLE DDL), then rebuild the
|
|
282
|
+
* table inside a transaction: copy rows verbatim into a new constraint-free
|
|
283
|
+
* schema, drop the original, and rename. Indexes are dropped by the swap and
|
|
284
|
+
* re-created by the caller via CREATE_INDEXES, which is idempotent.
|
|
285
|
+
*
|
|
286
|
+
* Safe to call multiple times — short-circuits when the recorded DDL no
|
|
287
|
+
* longer contains a CHECK on `state`. Must run BEFORE indexes are created
|
|
288
|
+
* (the swap drops them) and BEFORE the column-add migrations that read
|
|
289
|
+
* `PRAGMA table_info` on the legacy table (the new table inherits any added
|
|
290
|
+
* columns via the rebuild, so the column-add migrations become idempotent).
|
|
291
|
+
*/
|
|
292
|
+
function migrateRelaxStateCheck(db: Database): void {
|
|
293
|
+
const row = db
|
|
294
|
+
.prepare<{ sql: string | null }, []>(
|
|
295
|
+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
|
|
296
|
+
)
|
|
297
|
+
.get();
|
|
298
|
+
if (!row || row.sql === null) return;
|
|
299
|
+
// Detect the inline CHECK on the `state` column. Match conservatively on
|
|
300
|
+
// the literal "CHECK(state IN" — any whitespace variant SQLite stores
|
|
301
|
+
// will still contain this substring.
|
|
302
|
+
if (!row.sql.includes("CHECK(state IN")) return;
|
|
303
|
+
|
|
304
|
+
// Discover the columns that exist on the LIVE table so the rebuild copies
|
|
305
|
+
// every column the column-add migrations have layered on. Hard-coding the
|
|
306
|
+
// column list would silently drop newer columns when this migration runs
|
|
307
|
+
// against a DB that earlier migrations have already extended.
|
|
308
|
+
const colInfo = db.prepare("PRAGMA table_info(sessions)").all() as Array<{
|
|
309
|
+
name: string;
|
|
310
|
+
type: string;
|
|
311
|
+
notnull: number;
|
|
312
|
+
dflt_value: string | null;
|
|
313
|
+
pk: number;
|
|
314
|
+
}>;
|
|
315
|
+
|
|
316
|
+
// Render each column for the new CREATE TABLE. PRIMARY KEY and UNIQUE are
|
|
317
|
+
// preserved on `id` and `agent_name` respectively to match the original
|
|
318
|
+
// schema; everything else is straight type + nullability + default.
|
|
319
|
+
const colDefs = colInfo
|
|
320
|
+
.map((c) => {
|
|
321
|
+
const parts: string[] = [c.name, c.type || "TEXT"];
|
|
322
|
+
if (c.pk === 1) parts.push("PRIMARY KEY");
|
|
323
|
+
if (c.notnull === 1) parts.push("NOT NULL");
|
|
324
|
+
if (c.dflt_value !== null) parts.push(`DEFAULT ${c.dflt_value}`);
|
|
325
|
+
if (c.name === "agent_name") parts.push("UNIQUE");
|
|
326
|
+
return `\t\t\t\t${parts.join(" ")}`;
|
|
327
|
+
})
|
|
328
|
+
.join(",\n");
|
|
329
|
+
const colNames = colInfo.map((c) => c.name).join(", ");
|
|
330
|
+
|
|
331
|
+
db.exec("BEGIN");
|
|
332
|
+
try {
|
|
333
|
+
db.exec(`CREATE TABLE sessions__new_3087 (\n${colDefs}\n\t\t\t)`);
|
|
334
|
+
db.exec(`
|
|
335
|
+
INSERT INTO sessions__new_3087 (${colNames})
|
|
336
|
+
SELECT ${colNames} FROM sessions
|
|
337
|
+
`);
|
|
338
|
+
db.exec("DROP TABLE sessions");
|
|
339
|
+
db.exec("ALTER TABLE sessions__new_3087 RENAME TO sessions");
|
|
340
|
+
db.exec("COMMIT");
|
|
341
|
+
} catch (err) {
|
|
342
|
+
db.exec("ROLLBACK");
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
254
347
|
/**
|
|
255
348
|
* Migrate an existing sessions table from bead_id to task_id column.
|
|
256
349
|
* Safe to call multiple times — only renames if bead_id exists and task_id does not.
|
|
@@ -282,6 +375,10 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
282
375
|
db.exec(CREATE_RUNS_TABLE);
|
|
283
376
|
|
|
284
377
|
// Migrate existing tables BEFORE creating indexes that reference new columns.
|
|
378
|
+
// `migrateRelaxStateCheck` runs FIRST so the column-add migrations that
|
|
379
|
+
// follow operate on the rebuilt table — they read PRAGMA table_info and
|
|
380
|
+
// ADD COLUMN, both of which work on the new constraint-free schema.
|
|
381
|
+
migrateRelaxStateCheck(db);
|
|
285
382
|
migrateBeadIdToTaskId(db);
|
|
286
383
|
migrateAddTranscriptPath(db);
|
|
287
384
|
migrateAddPromptVersion(db);
|
|
@@ -353,7 +450,8 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
353
450
|
`);
|
|
354
451
|
|
|
355
452
|
const getActiveStmt = db.prepare<SessionRow, Record<string, never>>(`
|
|
356
|
-
SELECT * FROM sessions
|
|
453
|
+
SELECT * FROM sessions
|
|
454
|
+
WHERE state IN ('booting', 'working', 'in_turn', 'between_turns', 'stalled')
|
|
357
455
|
ORDER BY started_at ASC
|
|
358
456
|
`);
|
|
359
457
|
|
package/src/types.ts
CHANGED
|
@@ -187,7 +187,33 @@ export type Capability = (typeof SUPPORTED_CAPABILITIES)[number];
|
|
|
187
187
|
|
|
188
188
|
// === Agent Session ===
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Agent lifecycle states.
|
|
192
|
+
*
|
|
193
|
+
* `in_turn` and `between_turns` are spawn-per-turn-specific substates that
|
|
194
|
+
* split the legacy `working` state so the UI can distinguish a worker actively
|
|
195
|
+
* executing a turn from one idling between mail batches (overstory-3087):
|
|
196
|
+
*
|
|
197
|
+
* - `in_turn`: the turn-runner has observed at least one parser event from
|
|
198
|
+
* a live claude subprocess. The agent is mid-execution.
|
|
199
|
+
* - `between_turns`: the turn-runner finished a turn without a terminal
|
|
200
|
+
* mail; the agent is alive (process gone, session pinned) and waiting
|
|
201
|
+
* for the next mail batch to spawn a fresh turn.
|
|
202
|
+
*
|
|
203
|
+
* `working` remains the active state for tmux/long-lived headless agents
|
|
204
|
+
* (coordinator, orchestrator, monitor, sapling) which have no per-turn
|
|
205
|
+
* boundary. Spawn-per-turn workers (builder/scout/reviewer/lead/merger
|
|
206
|
+
* under the headless default) transition through in_turn ↔ between_turns
|
|
207
|
+
* instead.
|
|
208
|
+
*/
|
|
209
|
+
export type AgentState =
|
|
210
|
+
| "booting"
|
|
211
|
+
| "working"
|
|
212
|
+
| "in_turn"
|
|
213
|
+
| "between_turns"
|
|
214
|
+
| "completed"
|
|
215
|
+
| "stalled"
|
|
216
|
+
| "zombie";
|
|
191
217
|
|
|
192
218
|
/**
|
|
193
219
|
* Result of a guarded state transition attempt (`SessionStore.tryTransitionState`).
|
|
@@ -446,6 +472,13 @@ export interface OverlayConfig {
|
|
|
446
472
|
qualityGates?: QualityGate[];
|
|
447
473
|
/** Relative path to the instruction file within the worktree (runtime-specific). Defaults to .claude/CLAUDE.md. */
|
|
448
474
|
instructionPath?: string;
|
|
475
|
+
/**
|
|
476
|
+
* Names of sibling agents dispatched in parallel that may share file scope
|
|
477
|
+
* with this agent. When set, the overlay renders a "Parallel Siblings"
|
|
478
|
+
* section with rebase-before-merge_ready guidance (overstory-f76a). Empty
|
|
479
|
+
* or unset → no overlay section.
|
|
480
|
+
*/
|
|
481
|
+
siblings?: string[];
|
|
449
482
|
}
|
|
450
483
|
|
|
451
484
|
// === Merge Queue ===
|
|
@@ -491,6 +524,23 @@ export interface ConflictHistory {
|
|
|
491
524
|
predictedConflictFiles: string[];
|
|
492
525
|
}
|
|
493
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Side-effect-free prediction of how `ov merge` would resolve a branch.
|
|
529
|
+
* Produced by `predictConflicts` (src/merge/predict.ts) without touching HEAD,
|
|
530
|
+
* the working tree, or the merge lock — surfaced via `ov merge --dry-run` so a
|
|
531
|
+
* lead/operator/greenhouse can branch on `wouldRequireAgent`.
|
|
532
|
+
*/
|
|
533
|
+
export interface ConflictPrediction {
|
|
534
|
+
/** The tier `ov merge` would land in if invoked now. */
|
|
535
|
+
predictedTier: ResolutionTier;
|
|
536
|
+
/** Files that would conflict — empty for clean-merge. */
|
|
537
|
+
conflictFiles: string[];
|
|
538
|
+
/** True iff predictedTier is "ai-resolve" or "reimagine" (Tier 3+). */
|
|
539
|
+
wouldRequireAgent: boolean;
|
|
540
|
+
/** Short, operator-readable explanation for the predicted tier. */
|
|
541
|
+
reason: string;
|
|
542
|
+
}
|
|
543
|
+
|
|
494
544
|
// === Watchdog ===
|
|
495
545
|
|
|
496
546
|
export interface HealthCheck {
|
|
@@ -531,6 +531,123 @@ describe("daemon tick", () => {
|
|
|
531
531
|
expect(reloaded[0]?.stalledSince).toBeNull();
|
|
532
532
|
});
|
|
533
533
|
|
|
534
|
+
// Regression tests for overstory-74ce: killAgent() must never call
|
|
535
|
+
// tmux.killSession("") for headless agents — an empty `-t` argument is
|
|
536
|
+
// prefix-matched and would wildcard-kill the entire overstory tmux server.
|
|
537
|
+
|
|
538
|
+
test("spawn-per-turn agent at level 3 termination does NOT call tmux.killSession", async () => {
|
|
539
|
+
const nudgeIntervalMs = 60_000;
|
|
540
|
+
const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
|
|
541
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
542
|
+
|
|
543
|
+
// Spawn-per-turn worker between turns: tmuxSession === "" AND pid === null.
|
|
544
|
+
// Before the fix, killAgent fell through to tmux.killSession("") which
|
|
545
|
+
// prefix-matches every session in the overstory tmux server.
|
|
546
|
+
const session = makeSession({
|
|
547
|
+
agentName: "spawn-per-turn-doomed",
|
|
548
|
+
tmuxSession: "",
|
|
549
|
+
pid: null,
|
|
550
|
+
state: "stalled",
|
|
551
|
+
lastActivity: staleActivity,
|
|
552
|
+
escalationLevel: 2,
|
|
553
|
+
stalledSince,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
557
|
+
|
|
558
|
+
// No tmux sessions registered — emulates production where the spawn-per-turn
|
|
559
|
+
// agent has no named session.
|
|
560
|
+
const tmuxMock = tmuxWithLiveness({});
|
|
561
|
+
|
|
562
|
+
await runDaemonTick({
|
|
563
|
+
root: tempRoot,
|
|
564
|
+
...THRESHOLDS,
|
|
565
|
+
nudgeIntervalMs,
|
|
566
|
+
tier1Enabled: false,
|
|
567
|
+
_tmux: tmuxMock,
|
|
568
|
+
_triage: triageAlways("extend"),
|
|
569
|
+
_nudge: nudgeTracker().nudge,
|
|
570
|
+
_eventStore: null,
|
|
571
|
+
_recordFailure: async () => {},
|
|
572
|
+
_getConnection: () => undefined,
|
|
573
|
+
_removeConnection: () => {},
|
|
574
|
+
_tailerRegistry: new Map(),
|
|
575
|
+
_findLatestStdoutLog: async () => null,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Critical assertion: no wildcard kill attempt. tmuxMock.killed must be empty.
|
|
579
|
+
expect(tmuxMock.killed).toHaveLength(0);
|
|
580
|
+
|
|
581
|
+
// The session is still transitioned to zombie — termination semantics are preserved,
|
|
582
|
+
// just without the wildcard tmux kill.
|
|
583
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
584
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
585
|
+
expect(reloaded[0]?.escalationLevel).toBe(0);
|
|
586
|
+
expect(reloaded[0]?.stalledSince).toBeNull();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("long-lived headless agent at level 3 termination kills pid tree, not tmux", async () => {
|
|
590
|
+
const nudgeIntervalMs = 60_000;
|
|
591
|
+
const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
|
|
592
|
+
const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
|
|
593
|
+
|
|
594
|
+
// Long-lived headless capability (e.g. coordinator/orchestrator/monitor):
|
|
595
|
+
// tmuxSession === "" AND pid !== null. The PID tree should be killed; tmux
|
|
596
|
+
// must not be touched.
|
|
597
|
+
const session = makeSession({
|
|
598
|
+
agentName: "headless-long-lived-doomed",
|
|
599
|
+
tmuxSession: "",
|
|
600
|
+
pid: process.pid, // alive PID — health eval won't short-circuit to direct terminate
|
|
601
|
+
state: "stalled",
|
|
602
|
+
lastActivity: staleActivity,
|
|
603
|
+
escalationLevel: 2,
|
|
604
|
+
stalledSince,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
writeSessionsToStore(tempRoot, [session]);
|
|
608
|
+
|
|
609
|
+
const killedPids: number[] = [];
|
|
610
|
+
const procMock = {
|
|
611
|
+
isAlive: (pid: number) => {
|
|
612
|
+
try {
|
|
613
|
+
process.kill(pid, 0);
|
|
614
|
+
return true;
|
|
615
|
+
} catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
killTree: async (pid: number) => {
|
|
620
|
+
killedPids.push(pid);
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const tmuxMock = tmuxWithLiveness({});
|
|
625
|
+
|
|
626
|
+
await runDaemonTick({
|
|
627
|
+
root: tempRoot,
|
|
628
|
+
...THRESHOLDS,
|
|
629
|
+
nudgeIntervalMs,
|
|
630
|
+
tier1Enabled: false,
|
|
631
|
+
_tmux: tmuxMock,
|
|
632
|
+
_triage: triageAlways("extend"),
|
|
633
|
+
_nudge: nudgeTracker().nudge,
|
|
634
|
+
_process: procMock,
|
|
635
|
+
_eventStore: null,
|
|
636
|
+
_recordFailure: async () => {},
|
|
637
|
+
_getConnection: () => undefined,
|
|
638
|
+
_removeConnection: () => {},
|
|
639
|
+
_tailerRegistry: new Map(),
|
|
640
|
+
_findLatestStdoutLog: async () => null,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// PID tree was killed; tmux.killSession was never called.
|
|
644
|
+
expect(killedPids).toContain(process.pid);
|
|
645
|
+
expect(tmuxMock.killed).toHaveLength(0);
|
|
646
|
+
|
|
647
|
+
const reloaded = readSessionsFromStore(tempRoot);
|
|
648
|
+
expect(reloaded[0]?.state).toBe("zombie");
|
|
649
|
+
});
|
|
650
|
+
|
|
534
651
|
test("triage retry sends nudge with recovery message", async () => {
|
|
535
652
|
const staleActivity = new Date(Date.now() - 60_000).toISOString();
|
|
536
653
|
const stalledSince = new Date(Date.now() - 130_000).toISOString();
|
|
@@ -2765,10 +2882,15 @@ describe("headless agent stale detection via events.db (Bug 2)", () => {
|
|
|
2765
2882
|
});
|
|
2766
2883
|
|
|
2767
2884
|
// lastActivity refreshed from events.db → spawn-per-turn evaluation
|
|
2768
|
-
// path keeps the agent
|
|
2885
|
+
// path keeps the agent active (action=none), NOT zombie. The
|
|
2886
|
+
// healthy classification reports `between_turns` (overstory-3087)
|
|
2887
|
+
// for spawn-per-turn workers; the legacy `working` row stays at
|
|
2888
|
+
// `working` on disk because the matrix does not list `working` as
|
|
2889
|
+
// a predecessor of `between_turns` and the CAS rejects the write
|
|
2890
|
+
// (the substate cycle is reserved for the turn-runner).
|
|
2769
2891
|
expect(checks).toHaveLength(1);
|
|
2770
2892
|
expect(checks[0]?.action).toBe("none");
|
|
2771
|
-
expect(checks[0]?.state).toBe("
|
|
2893
|
+
expect(checks[0]?.state).toBe("between_turns");
|
|
2772
2894
|
|
|
2773
2895
|
const reloaded = readSessionsFromStore(tempRoot);
|
|
2774
2896
|
expect(reloaded[0]?.state).toBe("working");
|