@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
|
@@ -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", () => {
|
|
@@ -155,6 +161,46 @@ describe("upsert", () => {
|
|
|
155
161
|
});
|
|
156
162
|
});
|
|
157
163
|
|
|
164
|
+
// === claudeSessionId roundtrip via upsert ===
|
|
165
|
+
|
|
166
|
+
describe("claudeSessionId upsert roundtrip", () => {
|
|
167
|
+
test("undefined claudeSessionId leaves column null and field absent on roundtrip", () => {
|
|
168
|
+
store.upsert(makeSession());
|
|
169
|
+
const result = store.getByName("test-agent");
|
|
170
|
+
expect(result).not.toBeNull();
|
|
171
|
+
expect(Object.hasOwn(result ?? {}, "claudeSessionId")).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("claudeSessionId roundtrips correctly when set", () => {
|
|
175
|
+
store.upsert(makeSession({ claudeSessionId: "sess-roundtrip-xyz" }));
|
|
176
|
+
const result = store.getByName("test-agent");
|
|
177
|
+
expect(result?.claudeSessionId).toBe("sess-roundtrip-xyz");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// === updateClaudeSessionId ===
|
|
182
|
+
|
|
183
|
+
describe("updateClaudeSessionId", () => {
|
|
184
|
+
test("sets claude_session_id for an existing session; getByName returns it", () => {
|
|
185
|
+
store.upsert(makeSession());
|
|
186
|
+
store.updateClaudeSessionId("test-agent", "sess-pin-001");
|
|
187
|
+
const result = store.getByName("test-agent");
|
|
188
|
+
expect(result?.claudeSessionId).toBe("sess-pin-001");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("calling twice with the same value is idempotent", () => {
|
|
192
|
+
store.upsert(makeSession());
|
|
193
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
194
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
195
|
+
const result = store.getByName("test-agent");
|
|
196
|
+
expect(result?.claudeSessionId).toBe("sess-idempotent");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("calling for an unknown agent is a no-op (does not throw)", () => {
|
|
200
|
+
expect(() => store.updateClaudeSessionId("nonexistent", "sess-noop")).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
158
204
|
// === updateTranscriptPath ===
|
|
159
205
|
|
|
160
206
|
describe("updateTranscriptPath", () => {
|
|
@@ -336,9 +382,567 @@ describe("updateState", () => {
|
|
|
336
382
|
store.updateState("nonexistent", "completed");
|
|
337
383
|
});
|
|
338
384
|
|
|
339
|
-
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.
|
|
340
389
|
store.upsert(makeSession());
|
|
341
|
-
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);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// === tryTransitionState (matrix-guarded CAS) ===
|
|
396
|
+
|
|
397
|
+
describe("tryTransitionState", () => {
|
|
398
|
+
test("returns not_found for an unknown agent", () => {
|
|
399
|
+
const outcome = store.tryTransitionState("nonexistent", "completed");
|
|
400
|
+
expect(outcome.ok).toBe(false);
|
|
401
|
+
if (!outcome.ok) {
|
|
402
|
+
expect(outcome.reason).toBe("not_found");
|
|
403
|
+
expect(outcome.attempted).toBe("completed");
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("booting → working lands and returns prev/next", () => {
|
|
408
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
409
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
410
|
+
expect(outcome.ok).toBe(true);
|
|
411
|
+
if (outcome.ok) {
|
|
412
|
+
expect(outcome.prev).toBe("booting");
|
|
413
|
+
expect(outcome.next).toBe("working");
|
|
414
|
+
}
|
|
415
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("working → completed lands (clean turn-end settle)", () => {
|
|
419
|
+
store.upsert(makeSession({ state: "working" }));
|
|
420
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
421
|
+
expect(outcome.ok).toBe(true);
|
|
422
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("working → zombie lands (watchdog terminate)", () => {
|
|
426
|
+
store.upsert(makeSession({ state: "working" }));
|
|
427
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
428
|
+
expect(outcome.ok).toBe(true);
|
|
429
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("zombie → completed lands (ov stop cleanup of zombie)", () => {
|
|
433
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
434
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
435
|
+
expect(outcome.ok).toBe(true);
|
|
436
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("completed → zombie is rejected (sticky completed)", () => {
|
|
440
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
441
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
442
|
+
expect(outcome.ok).toBe(false);
|
|
443
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
444
|
+
expect(outcome.prev).toBe("completed");
|
|
445
|
+
expect(outcome.attempted).toBe("zombie");
|
|
446
|
+
}
|
|
447
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("completed → working is rejected (turn-runner cannot revive completed)", () => {
|
|
451
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
452
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
453
|
+
expect(outcome.ok).toBe(false);
|
|
454
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
455
|
+
expect(outcome.prev).toBe("completed");
|
|
456
|
+
}
|
|
457
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("zombie → working is rejected (PreToolUse hook cannot revive zombie)", () => {
|
|
461
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
462
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
463
|
+
expect(outcome.ok).toBe(false);
|
|
464
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
465
|
+
expect(outcome.prev).toBe("zombie");
|
|
466
|
+
expect(outcome.attempted).toBe("working");
|
|
467
|
+
}
|
|
468
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("idempotent same-state transitions land (working → working)", () => {
|
|
472
|
+
store.upsert(makeSession({ state: "working" }));
|
|
473
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
474
|
+
expect(outcome.ok).toBe(true);
|
|
475
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("idempotent completed → completed is allowed", () => {
|
|
479
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
480
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
481
|
+
expect(outcome.ok).toBe(true);
|
|
482
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("idempotent zombie → zombie is allowed", () => {
|
|
486
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
487
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
488
|
+
expect(outcome.ok).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("nothing transitions into booting (matrix has no allowed predecessors)", () => {
|
|
492
|
+
store.upsert(makeSession({ state: "working" }));
|
|
493
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
494
|
+
expect(outcome.ok).toBe(false);
|
|
495
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
496
|
+
expect(outcome.prev).toBe("working");
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("stalled → working is allowed (recovery)", () => {
|
|
501
|
+
store.upsert(makeSession({ state: "stalled" }));
|
|
502
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
503
|
+
expect(outcome.ok).toBe(true);
|
|
504
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("race scenario: ov stop wins, late watchdog zombie write rejected", () => {
|
|
508
|
+
// Models the overstory-a993 symptom directly: ov stop completes the
|
|
509
|
+
// agent first; a stale watchdog tick then tries to mark it zombie.
|
|
510
|
+
store.upsert(makeSession({ state: "working" }));
|
|
511
|
+
|
|
512
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
513
|
+
expect(stopOutcome.ok).toBe(true);
|
|
514
|
+
|
|
515
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
516
|
+
expect(watchdogOutcome.ok).toBe(false);
|
|
517
|
+
if (!watchdogOutcome.ok && watchdogOutcome.reason === "illegal_transition") {
|
|
518
|
+
expect(watchdogOutcome.prev).toBe("completed");
|
|
519
|
+
}
|
|
520
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("race scenario: watchdog wins zombie, late turn-runner working write rejected", () => {
|
|
524
|
+
// Watchdog observes the agent dead and marks zombie; meanwhile a
|
|
525
|
+
// turn-runner whose initialState was 'working' tries to settle to
|
|
526
|
+
// 'working' at end of turn. The settle must NOT undo the zombie call.
|
|
527
|
+
store.upsert(makeSession({ state: "working" }));
|
|
528
|
+
|
|
529
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
530
|
+
expect(watchdogOutcome.ok).toBe(true);
|
|
531
|
+
|
|
532
|
+
const turnRunnerOutcome = store.tryTransitionState("test-agent", "working");
|
|
533
|
+
expect(turnRunnerOutcome.ok).toBe(false);
|
|
534
|
+
if (!turnRunnerOutcome.ok && turnRunnerOutcome.reason === "illegal_transition") {
|
|
535
|
+
expect(turnRunnerOutcome.prev).toBe("zombie");
|
|
536
|
+
}
|
|
537
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("race scenario: ov stop promotes a zombie to completed", () => {
|
|
541
|
+
// Inverse of the previous race: watchdog already marked zombie, then
|
|
542
|
+
// the operator runs `ov stop` to clean up. The cleanup must succeed.
|
|
543
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
544
|
+
|
|
545
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
546
|
+
expect(stopOutcome.ok).toBe(true);
|
|
547
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("concurrent CAS: two writers try the same target, only one observes ok", () => {
|
|
551
|
+
// Simulates the SQL CAS exclusivity: two writers race against the same
|
|
552
|
+
// row with the same target. The DB serializes the writes; only the one
|
|
553
|
+
// that finds the row in an allowed-from state when the CAS executes
|
|
554
|
+
// reports ok=true. We can't truly run them in parallel from a single
|
|
555
|
+
// thread, but we can prove the invariant: after BOTH calls, the row
|
|
556
|
+
// is in the target state and exactly one call returned ok=true.
|
|
557
|
+
store.upsert(makeSession({ state: "working" }));
|
|
558
|
+
|
|
559
|
+
const a = store.tryTransitionState("test-agent", "completed");
|
|
560
|
+
const b = store.tryTransitionState("test-agent", "completed");
|
|
561
|
+
|
|
562
|
+
// First call lands (working → completed). Second is idempotent
|
|
563
|
+
// (completed → completed is in the matrix), so both report ok.
|
|
564
|
+
expect(a.ok).toBe(true);
|
|
565
|
+
expect(b.ok).toBe(true);
|
|
566
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("concurrent CAS: conflicting targets — second writer rejected", () => {
|
|
570
|
+
// First writer lands working → completed; second writer attempts
|
|
571
|
+
// working → zombie but the row is now completed → REJECTED.
|
|
572
|
+
store.upsert(makeSession({ state: "working" }));
|
|
573
|
+
|
|
574
|
+
const stop = store.tryTransitionState("test-agent", "completed");
|
|
575
|
+
expect(stop.ok).toBe(true);
|
|
576
|
+
|
|
577
|
+
const watchdog = store.tryTransitionState("test-agent", "zombie");
|
|
578
|
+
expect(watchdog.ok).toBe(false);
|
|
579
|
+
if (!watchdog.ok && watchdog.reason === "illegal_transition") {
|
|
580
|
+
expect(watchdog.prev).toBe("completed");
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
});
|
|
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
|
+
|
|
837
|
+
// === tmux_session clearing on terminal transitions (overstory-14c0) ===
|
|
838
|
+
//
|
|
839
|
+
// The tmux session is torn down by ov stop / watchdog / coordinator cleanup
|
|
840
|
+
// before the state lands at completed/zombie. The stored tmux_session string
|
|
841
|
+
// would otherwise stay forever, surfacing dead session names in the agents
|
|
842
|
+
// view of `ov status`. Both updateState and tryTransitionState must clear it.
|
|
843
|
+
|
|
844
|
+
describe("tmux_session clearing on terminal transitions", () => {
|
|
845
|
+
const tmux = "overstory-test-agent";
|
|
846
|
+
|
|
847
|
+
describe("updateState", () => {
|
|
848
|
+
test("clears tmux_session when transitioning to completed", () => {
|
|
849
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
850
|
+
store.updateState("test-agent", "completed");
|
|
851
|
+
const result = store.getByName("test-agent");
|
|
852
|
+
expect(result?.state).toBe("completed");
|
|
853
|
+
expect(result?.tmuxSession).toBe("");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("clears tmux_session when transitioning to zombie", () => {
|
|
857
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
858
|
+
store.updateState("test-agent", "zombie");
|
|
859
|
+
const result = store.getByName("test-agent");
|
|
860
|
+
expect(result?.state).toBe("zombie");
|
|
861
|
+
expect(result?.tmuxSession).toBe("");
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("preserves tmux_session when transitioning to a non-terminal state", () => {
|
|
865
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
866
|
+
store.updateState("test-agent", "working");
|
|
867
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
868
|
+
|
|
869
|
+
store.updateState("test-agent", "stalled");
|
|
870
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("idempotent terminal write keeps tmux_session cleared", () => {
|
|
874
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
875
|
+
store.updateState("test-agent", "completed");
|
|
876
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
describe("tryTransitionState", () => {
|
|
881
|
+
test("clears tmux_session on working → completed", () => {
|
|
882
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
883
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
884
|
+
expect(outcome.ok).toBe(true);
|
|
885
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("clears tmux_session on working → zombie", () => {
|
|
889
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
890
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
891
|
+
expect(outcome.ok).toBe(true);
|
|
892
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("clears tmux_session on zombie → completed (ov stop cleanup of zombie)", () => {
|
|
896
|
+
// Zombie may still hold a tmux_session if the watchdog landed before
|
|
897
|
+
// any cleanup pass; the subsequent ov stop must wipe it.
|
|
898
|
+
store.upsert(makeSession({ state: "zombie", tmuxSession: tmux }));
|
|
899
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
900
|
+
expect(outcome.ok).toBe(true);
|
|
901
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("clears tmux_session on stalled → zombie (watchdog terminate)", () => {
|
|
905
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
906
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
907
|
+
expect(outcome.ok).toBe(true);
|
|
908
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("preserves tmux_session on non-terminal targets (booting → working)", () => {
|
|
912
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
913
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
914
|
+
expect(outcome.ok).toBe(true);
|
|
915
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("preserves tmux_session on non-terminal targets (stalled → working)", () => {
|
|
919
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
920
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
921
|
+
expect(outcome.ok).toBe(true);
|
|
922
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("does not clear tmux_session when CAS rejects an illegal terminal transition", () => {
|
|
926
|
+
// completed → zombie is rejected. The row must remain untouched —
|
|
927
|
+
// in particular, tmux_session must keep whatever the row already
|
|
928
|
+
// held (an empty string here, since the prior completed write
|
|
929
|
+
// cleared it).
|
|
930
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
931
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
932
|
+
expect(outcome.ok).toBe(false);
|
|
933
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("does not clear tmux_session when CAS rejects against a live row", () => {
|
|
937
|
+
// nothing transitions into booting; a working row keeps both state
|
|
938
|
+
// and tmux_session.
|
|
939
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
940
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
941
|
+
expect(outcome.ok).toBe(false);
|
|
942
|
+
const result = store.getByName("test-agent");
|
|
943
|
+
expect(result?.state).toBe("working");
|
|
944
|
+
expect(result?.tmuxSession).toBe(tmux);
|
|
945
|
+
});
|
|
342
946
|
});
|
|
343
947
|
});
|
|
344
948
|
|
|
@@ -677,11 +1281,29 @@ describe("RunStore", () => {
|
|
|
677
1281
|
expect(result).toBeNull();
|
|
678
1282
|
});
|
|
679
1283
|
|
|
680
|
-
test("
|
|
1284
|
+
test("agent count is derived from sessions in the same run", () => {
|
|
681
1285
|
runStore.createRun(makeRun({ agentCount: 5 }));
|
|
1286
|
+
// `agentCount` no longer reflects the column; it's a live count of
|
|
1287
|
+
// sessions whose `run_id` matches. With no sessions, count is 0.
|
|
1288
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(0);
|
|
682
1289
|
|
|
683
|
-
|
|
684
|
-
|
|
1290
|
+
store.upsert(
|
|
1291
|
+
makeSession({
|
|
1292
|
+
id: "s-1",
|
|
1293
|
+
agentName: "a-1",
|
|
1294
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1295
|
+
}),
|
|
1296
|
+
);
|
|
1297
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(1);
|
|
1298
|
+
|
|
1299
|
+
store.upsert(
|
|
1300
|
+
makeSession({
|
|
1301
|
+
id: "s-2",
|
|
1302
|
+
agentName: "a-2",
|
|
1303
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1304
|
+
}),
|
|
1305
|
+
);
|
|
1306
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(2);
|
|
685
1307
|
});
|
|
686
1308
|
|
|
687
1309
|
test("creates a run with null coordinatorSessionId", () => {
|
|
@@ -840,33 +1462,24 @@ describe("RunStore", () => {
|
|
|
840
1462
|
});
|
|
841
1463
|
|
|
842
1464
|
// === incrementAgentCount ===
|
|
1465
|
+
//
|
|
1466
|
+
// Retained as a no-op for API compatibility. `agentCount` is now derived
|
|
1467
|
+
// from the sessions table at read time (overstory-8e69), so calls have no
|
|
1468
|
+
// effect on what `getRun` returns.
|
|
843
1469
|
|
|
844
1470
|
describe("incrementAgentCount", () => {
|
|
845
|
-
test("
|
|
1471
|
+
test("is a no-op — agent count comes from sessions, not the column", () => {
|
|
846
1472
|
runStore.createRun(makeRun());
|
|
847
1473
|
|
|
848
1474
|
runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
|
|
849
|
-
let result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
850
|
-
expect(result?.agentCount).toBe(1);
|
|
851
|
-
|
|
852
1475
|
runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
|
|
853
|
-
result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
854
|
-
expect(result?.agentCount).toBe(2);
|
|
855
|
-
});
|
|
856
1476
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
runStore.incrementAgentCount("nonexistent-run");
|
|
1477
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1478
|
+
expect(result?.agentCount).toBe(0);
|
|
860
1479
|
});
|
|
861
1480
|
|
|
862
|
-
test("
|
|
863
|
-
runStore.
|
|
864
|
-
runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
|
|
865
|
-
|
|
866
|
-
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
867
|
-
expect(result?.status).toBe("active");
|
|
868
|
-
expect(result?.completedAt).toBeNull();
|
|
869
|
-
expect(result?.coordinatorSessionId).toBe("coord-session-001");
|
|
1481
|
+
test("is safe to call for a nonexistent run (does not throw)", () => {
|
|
1482
|
+
runStore.incrementAgentCount("nonexistent-run");
|
|
870
1483
|
});
|
|
871
1484
|
});
|
|
872
1485
|
|
|
@@ -904,9 +1517,15 @@ describe("RunStore", () => {
|
|
|
904
1517
|
|
|
905
1518
|
test("preserves agent count when completing", () => {
|
|
906
1519
|
runStore.createRun(makeRun());
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1520
|
+
for (let i = 0; i < 3; i++) {
|
|
1521
|
+
store.upsert(
|
|
1522
|
+
makeSession({
|
|
1523
|
+
id: `s-${i}`,
|
|
1524
|
+
agentName: `a-${i}`,
|
|
1525
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1526
|
+
}),
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
910
1529
|
|
|
911
1530
|
runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
|
|
912
1531
|
|
|
@@ -1106,6 +1725,15 @@ describe("RunStore", () => {
|
|
|
1106
1725
|
};
|
|
1107
1726
|
|
|
1108
1727
|
runStore.createRun(run);
|
|
1728
|
+
for (let i = 0; i < 7; i++) {
|
|
1729
|
+
store.upsert(
|
|
1730
|
+
makeSession({
|
|
1731
|
+
id: `s-rt-${i}`,
|
|
1732
|
+
agentName: `a-rt-${i}`,
|
|
1733
|
+
runId: "run-roundtrip-test",
|
|
1734
|
+
}),
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1109
1737
|
const result = runStore.getRun("run-roundtrip-test");
|
|
1110
1738
|
|
|
1111
1739
|
expect(result).not.toBeNull();
|