@os-eco/overstory-cli 0.9.3 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- 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 +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -155,6 +155,46 @@ describe("upsert", () => {
|
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// === claudeSessionId roundtrip via upsert ===
|
|
159
|
+
|
|
160
|
+
describe("claudeSessionId upsert roundtrip", () => {
|
|
161
|
+
test("undefined claudeSessionId leaves column null and field absent on roundtrip", () => {
|
|
162
|
+
store.upsert(makeSession());
|
|
163
|
+
const result = store.getByName("test-agent");
|
|
164
|
+
expect(result).not.toBeNull();
|
|
165
|
+
expect(Object.hasOwn(result ?? {}, "claudeSessionId")).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("claudeSessionId roundtrips correctly when set", () => {
|
|
169
|
+
store.upsert(makeSession({ claudeSessionId: "sess-roundtrip-xyz" }));
|
|
170
|
+
const result = store.getByName("test-agent");
|
|
171
|
+
expect(result?.claudeSessionId).toBe("sess-roundtrip-xyz");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// === updateClaudeSessionId ===
|
|
176
|
+
|
|
177
|
+
describe("updateClaudeSessionId", () => {
|
|
178
|
+
test("sets claude_session_id for an existing session; getByName returns it", () => {
|
|
179
|
+
store.upsert(makeSession());
|
|
180
|
+
store.updateClaudeSessionId("test-agent", "sess-pin-001");
|
|
181
|
+
const result = store.getByName("test-agent");
|
|
182
|
+
expect(result?.claudeSessionId).toBe("sess-pin-001");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("calling twice with the same value is idempotent", () => {
|
|
186
|
+
store.upsert(makeSession());
|
|
187
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
188
|
+
store.updateClaudeSessionId("test-agent", "sess-idempotent");
|
|
189
|
+
const result = store.getByName("test-agent");
|
|
190
|
+
expect(result?.claudeSessionId).toBe("sess-idempotent");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("calling for an unknown agent is a no-op (does not throw)", () => {
|
|
194
|
+
expect(() => store.updateClaudeSessionId("nonexistent", "sess-noop")).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
158
198
|
// === updateTranscriptPath ===
|
|
159
199
|
|
|
160
200
|
describe("updateTranscriptPath", () => {
|
|
@@ -342,6 +382,308 @@ describe("updateState", () => {
|
|
|
342
382
|
});
|
|
343
383
|
});
|
|
344
384
|
|
|
385
|
+
// === tryTransitionState (matrix-guarded CAS) ===
|
|
386
|
+
|
|
387
|
+
describe("tryTransitionState", () => {
|
|
388
|
+
test("returns not_found for an unknown agent", () => {
|
|
389
|
+
const outcome = store.tryTransitionState("nonexistent", "completed");
|
|
390
|
+
expect(outcome.ok).toBe(false);
|
|
391
|
+
if (!outcome.ok) {
|
|
392
|
+
expect(outcome.reason).toBe("not_found");
|
|
393
|
+
expect(outcome.attempted).toBe("completed");
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("booting → working lands and returns prev/next", () => {
|
|
398
|
+
store.upsert(makeSession({ state: "booting" }));
|
|
399
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
400
|
+
expect(outcome.ok).toBe(true);
|
|
401
|
+
if (outcome.ok) {
|
|
402
|
+
expect(outcome.prev).toBe("booting");
|
|
403
|
+
expect(outcome.next).toBe("working");
|
|
404
|
+
}
|
|
405
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("working → completed lands (clean turn-end settle)", () => {
|
|
409
|
+
store.upsert(makeSession({ state: "working" }));
|
|
410
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
411
|
+
expect(outcome.ok).toBe(true);
|
|
412
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("working → zombie lands (watchdog terminate)", () => {
|
|
416
|
+
store.upsert(makeSession({ state: "working" }));
|
|
417
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
418
|
+
expect(outcome.ok).toBe(true);
|
|
419
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("zombie → completed lands (ov stop cleanup of zombie)", () => {
|
|
423
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
424
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
425
|
+
expect(outcome.ok).toBe(true);
|
|
426
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("completed → zombie is rejected (sticky completed)", () => {
|
|
430
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
431
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
432
|
+
expect(outcome.ok).toBe(false);
|
|
433
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
434
|
+
expect(outcome.prev).toBe("completed");
|
|
435
|
+
expect(outcome.attempted).toBe("zombie");
|
|
436
|
+
}
|
|
437
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("completed → working is rejected (turn-runner cannot revive completed)", () => {
|
|
441
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
442
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
443
|
+
expect(outcome.ok).toBe(false);
|
|
444
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
445
|
+
expect(outcome.prev).toBe("completed");
|
|
446
|
+
}
|
|
447
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("zombie → working is rejected (PreToolUse hook cannot revive zombie)", () => {
|
|
451
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
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("zombie");
|
|
456
|
+
expect(outcome.attempted).toBe("working");
|
|
457
|
+
}
|
|
458
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("idempotent same-state transitions land (working → working)", () => {
|
|
462
|
+
store.upsert(makeSession({ state: "working" }));
|
|
463
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
464
|
+
expect(outcome.ok).toBe(true);
|
|
465
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("idempotent completed → completed is allowed", () => {
|
|
469
|
+
store.upsert(makeSession({ state: "completed" }));
|
|
470
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
471
|
+
expect(outcome.ok).toBe(true);
|
|
472
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("idempotent zombie → zombie is allowed", () => {
|
|
476
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
477
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
478
|
+
expect(outcome.ok).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("nothing transitions into booting (matrix has no allowed predecessors)", () => {
|
|
482
|
+
store.upsert(makeSession({ state: "working" }));
|
|
483
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
484
|
+
expect(outcome.ok).toBe(false);
|
|
485
|
+
if (!outcome.ok && outcome.reason === "illegal_transition") {
|
|
486
|
+
expect(outcome.prev).toBe("working");
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("stalled → working is allowed (recovery)", () => {
|
|
491
|
+
store.upsert(makeSession({ state: "stalled" }));
|
|
492
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
493
|
+
expect(outcome.ok).toBe(true);
|
|
494
|
+
expect(store.getByName("test-agent")?.state).toBe("working");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("race scenario: ov stop wins, late watchdog zombie write rejected", () => {
|
|
498
|
+
// Models the overstory-a993 symptom directly: ov stop completes the
|
|
499
|
+
// agent first; a stale watchdog tick then tries to mark it zombie.
|
|
500
|
+
store.upsert(makeSession({ state: "working" }));
|
|
501
|
+
|
|
502
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
503
|
+
expect(stopOutcome.ok).toBe(true);
|
|
504
|
+
|
|
505
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
506
|
+
expect(watchdogOutcome.ok).toBe(false);
|
|
507
|
+
if (!watchdogOutcome.ok && watchdogOutcome.reason === "illegal_transition") {
|
|
508
|
+
expect(watchdogOutcome.prev).toBe("completed");
|
|
509
|
+
}
|
|
510
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("race scenario: watchdog wins zombie, late turn-runner working write rejected", () => {
|
|
514
|
+
// Watchdog observes the agent dead and marks zombie; meanwhile a
|
|
515
|
+
// turn-runner whose initialState was 'working' tries to settle to
|
|
516
|
+
// 'working' at end of turn. The settle must NOT undo the zombie call.
|
|
517
|
+
store.upsert(makeSession({ state: "working" }));
|
|
518
|
+
|
|
519
|
+
const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
|
|
520
|
+
expect(watchdogOutcome.ok).toBe(true);
|
|
521
|
+
|
|
522
|
+
const turnRunnerOutcome = store.tryTransitionState("test-agent", "working");
|
|
523
|
+
expect(turnRunnerOutcome.ok).toBe(false);
|
|
524
|
+
if (!turnRunnerOutcome.ok && turnRunnerOutcome.reason === "illegal_transition") {
|
|
525
|
+
expect(turnRunnerOutcome.prev).toBe("zombie");
|
|
526
|
+
}
|
|
527
|
+
expect(store.getByName("test-agent")?.state).toBe("zombie");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("race scenario: ov stop promotes a zombie to completed", () => {
|
|
531
|
+
// Inverse of the previous race: watchdog already marked zombie, then
|
|
532
|
+
// the operator runs `ov stop` to clean up. The cleanup must succeed.
|
|
533
|
+
store.upsert(makeSession({ state: "zombie" }));
|
|
534
|
+
|
|
535
|
+
const stopOutcome = store.tryTransitionState("test-agent", "completed");
|
|
536
|
+
expect(stopOutcome.ok).toBe(true);
|
|
537
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("concurrent CAS: two writers try the same target, only one observes ok", () => {
|
|
541
|
+
// Simulates the SQL CAS exclusivity: two writers race against the same
|
|
542
|
+
// row with the same target. The DB serializes the writes; only the one
|
|
543
|
+
// that finds the row in an allowed-from state when the CAS executes
|
|
544
|
+
// reports ok=true. We can't truly run them in parallel from a single
|
|
545
|
+
// thread, but we can prove the invariant: after BOTH calls, the row
|
|
546
|
+
// is in the target state and exactly one call returned ok=true.
|
|
547
|
+
store.upsert(makeSession({ state: "working" }));
|
|
548
|
+
|
|
549
|
+
const a = store.tryTransitionState("test-agent", "completed");
|
|
550
|
+
const b = store.tryTransitionState("test-agent", "completed");
|
|
551
|
+
|
|
552
|
+
// First call lands (working → completed). Second is idempotent
|
|
553
|
+
// (completed → completed is in the matrix), so both report ok.
|
|
554
|
+
expect(a.ok).toBe(true);
|
|
555
|
+
expect(b.ok).toBe(true);
|
|
556
|
+
expect(store.getByName("test-agent")?.state).toBe("completed");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("concurrent CAS: conflicting targets — second writer rejected", () => {
|
|
560
|
+
// First writer lands working → completed; second writer attempts
|
|
561
|
+
// working → zombie but the row is now completed → REJECTED.
|
|
562
|
+
store.upsert(makeSession({ state: "working" }));
|
|
563
|
+
|
|
564
|
+
const stop = store.tryTransitionState("test-agent", "completed");
|
|
565
|
+
expect(stop.ok).toBe(true);
|
|
566
|
+
|
|
567
|
+
const watchdog = store.tryTransitionState("test-agent", "zombie");
|
|
568
|
+
expect(watchdog.ok).toBe(false);
|
|
569
|
+
if (!watchdog.ok && watchdog.reason === "illegal_transition") {
|
|
570
|
+
expect(watchdog.prev).toBe("completed");
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// === tmux_session clearing on terminal transitions (overstory-14c0) ===
|
|
576
|
+
//
|
|
577
|
+
// The tmux session is torn down by ov stop / watchdog / coordinator cleanup
|
|
578
|
+
// before the state lands at completed/zombie. The stored tmux_session string
|
|
579
|
+
// would otherwise stay forever, surfacing dead session names in the agents
|
|
580
|
+
// view of `ov status`. Both updateState and tryTransitionState must clear it.
|
|
581
|
+
|
|
582
|
+
describe("tmux_session clearing on terminal transitions", () => {
|
|
583
|
+
const tmux = "overstory-test-agent";
|
|
584
|
+
|
|
585
|
+
describe("updateState", () => {
|
|
586
|
+
test("clears tmux_session when transitioning to completed", () => {
|
|
587
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
588
|
+
store.updateState("test-agent", "completed");
|
|
589
|
+
const result = store.getByName("test-agent");
|
|
590
|
+
expect(result?.state).toBe("completed");
|
|
591
|
+
expect(result?.tmuxSession).toBe("");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("clears tmux_session when transitioning to zombie", () => {
|
|
595
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
596
|
+
store.updateState("test-agent", "zombie");
|
|
597
|
+
const result = store.getByName("test-agent");
|
|
598
|
+
expect(result?.state).toBe("zombie");
|
|
599
|
+
expect(result?.tmuxSession).toBe("");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("preserves tmux_session when transitioning to a non-terminal state", () => {
|
|
603
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
604
|
+
store.updateState("test-agent", "working");
|
|
605
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
606
|
+
|
|
607
|
+
store.updateState("test-agent", "stalled");
|
|
608
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("idempotent terminal write keeps tmux_session cleared", () => {
|
|
612
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
613
|
+
store.updateState("test-agent", "completed");
|
|
614
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe("tryTransitionState", () => {
|
|
619
|
+
test("clears tmux_session on working → completed", () => {
|
|
620
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
621
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
622
|
+
expect(outcome.ok).toBe(true);
|
|
623
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("clears tmux_session on working → zombie", () => {
|
|
627
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
628
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
629
|
+
expect(outcome.ok).toBe(true);
|
|
630
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("clears tmux_session on zombie → completed (ov stop cleanup of zombie)", () => {
|
|
634
|
+
// Zombie may still hold a tmux_session if the watchdog landed before
|
|
635
|
+
// any cleanup pass; the subsequent ov stop must wipe it.
|
|
636
|
+
store.upsert(makeSession({ state: "zombie", tmuxSession: tmux }));
|
|
637
|
+
const outcome = store.tryTransitionState("test-agent", "completed");
|
|
638
|
+
expect(outcome.ok).toBe(true);
|
|
639
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("clears tmux_session on stalled → zombie (watchdog terminate)", () => {
|
|
643
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
644
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
645
|
+
expect(outcome.ok).toBe(true);
|
|
646
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("preserves tmux_session on non-terminal targets (booting → working)", () => {
|
|
650
|
+
store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
|
|
651
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
652
|
+
expect(outcome.ok).toBe(true);
|
|
653
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("preserves tmux_session on non-terminal targets (stalled → working)", () => {
|
|
657
|
+
store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
|
|
658
|
+
const outcome = store.tryTransitionState("test-agent", "working");
|
|
659
|
+
expect(outcome.ok).toBe(true);
|
|
660
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("does not clear tmux_session when CAS rejects an illegal terminal transition", () => {
|
|
664
|
+
// completed → zombie is rejected. The row must remain untouched —
|
|
665
|
+
// in particular, tmux_session must keep whatever the row already
|
|
666
|
+
// held (an empty string here, since the prior completed write
|
|
667
|
+
// cleared it).
|
|
668
|
+
store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
|
|
669
|
+
const outcome = store.tryTransitionState("test-agent", "zombie");
|
|
670
|
+
expect(outcome.ok).toBe(false);
|
|
671
|
+
expect(store.getByName("test-agent")?.tmuxSession).toBe("");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("does not clear tmux_session when CAS rejects against a live row", () => {
|
|
675
|
+
// nothing transitions into booting; a working row keeps both state
|
|
676
|
+
// and tmux_session.
|
|
677
|
+
store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
|
|
678
|
+
const outcome = store.tryTransitionState("test-agent", "booting");
|
|
679
|
+
expect(outcome.ok).toBe(false);
|
|
680
|
+
const result = store.getByName("test-agent");
|
|
681
|
+
expect(result?.state).toBe("working");
|
|
682
|
+
expect(result?.tmuxSession).toBe(tmux);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
345
687
|
// === updateLastActivity ===
|
|
346
688
|
|
|
347
689
|
describe("updateLastActivity", () => {
|
|
@@ -677,11 +1019,29 @@ describe("RunStore", () => {
|
|
|
677
1019
|
expect(result).toBeNull();
|
|
678
1020
|
});
|
|
679
1021
|
|
|
680
|
-
test("
|
|
1022
|
+
test("agent count is derived from sessions in the same run", () => {
|
|
681
1023
|
runStore.createRun(makeRun({ agentCount: 5 }));
|
|
1024
|
+
// `agentCount` no longer reflects the column; it's a live count of
|
|
1025
|
+
// sessions whose `run_id` matches. With no sessions, count is 0.
|
|
1026
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(0);
|
|
682
1027
|
|
|
683
|
-
|
|
684
|
-
|
|
1028
|
+
store.upsert(
|
|
1029
|
+
makeSession({
|
|
1030
|
+
id: "s-1",
|
|
1031
|
+
agentName: "a-1",
|
|
1032
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1033
|
+
}),
|
|
1034
|
+
);
|
|
1035
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(1);
|
|
1036
|
+
|
|
1037
|
+
store.upsert(
|
|
1038
|
+
makeSession({
|
|
1039
|
+
id: "s-2",
|
|
1040
|
+
agentName: "a-2",
|
|
1041
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1042
|
+
}),
|
|
1043
|
+
);
|
|
1044
|
+
expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(2);
|
|
685
1045
|
});
|
|
686
1046
|
|
|
687
1047
|
test("creates a run with null coordinatorSessionId", () => {
|
|
@@ -840,33 +1200,24 @@ describe("RunStore", () => {
|
|
|
840
1200
|
});
|
|
841
1201
|
|
|
842
1202
|
// === incrementAgentCount ===
|
|
1203
|
+
//
|
|
1204
|
+
// Retained as a no-op for API compatibility. `agentCount` is now derived
|
|
1205
|
+
// from the sessions table at read time (overstory-8e69), so calls have no
|
|
1206
|
+
// effect on what `getRun` returns.
|
|
843
1207
|
|
|
844
1208
|
describe("incrementAgentCount", () => {
|
|
845
|
-
test("
|
|
1209
|
+
test("is a no-op — agent count comes from sessions, not the column", () => {
|
|
846
1210
|
runStore.createRun(makeRun());
|
|
847
1211
|
|
|
848
1212
|
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
1213
|
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
1214
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
runStore.incrementAgentCount("nonexistent-run");
|
|
1215
|
+
const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
|
|
1216
|
+
expect(result?.agentCount).toBe(0);
|
|
860
1217
|
});
|
|
861
1218
|
|
|
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");
|
|
1219
|
+
test("is safe to call for a nonexistent run (does not throw)", () => {
|
|
1220
|
+
runStore.incrementAgentCount("nonexistent-run");
|
|
870
1221
|
});
|
|
871
1222
|
});
|
|
872
1223
|
|
|
@@ -904,9 +1255,15 @@ describe("RunStore", () => {
|
|
|
904
1255
|
|
|
905
1256
|
test("preserves agent count when completing", () => {
|
|
906
1257
|
runStore.createRun(makeRun());
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1258
|
+
for (let i = 0; i < 3; i++) {
|
|
1259
|
+
store.upsert(
|
|
1260
|
+
makeSession({
|
|
1261
|
+
id: `s-${i}`,
|
|
1262
|
+
agentName: `a-${i}`,
|
|
1263
|
+
runId: "run-2026-02-13T10:00:00.000Z",
|
|
1264
|
+
}),
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
910
1267
|
|
|
911
1268
|
runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
|
|
912
1269
|
|
|
@@ -1106,6 +1463,15 @@ describe("RunStore", () => {
|
|
|
1106
1463
|
};
|
|
1107
1464
|
|
|
1108
1465
|
runStore.createRun(run);
|
|
1466
|
+
for (let i = 0; i < 7; i++) {
|
|
1467
|
+
store.upsert(
|
|
1468
|
+
makeSession({
|
|
1469
|
+
id: `s-rt-${i}`,
|
|
1470
|
+
agentName: `a-rt-${i}`,
|
|
1471
|
+
runId: "run-roundtrip-test",
|
|
1472
|
+
}),
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1109
1475
|
const result = runStore.getRun("run-roundtrip-test");
|
|
1110
1476
|
|
|
1111
1477
|
expect(result).not.toBeNull();
|