@os-eco/overstory-cli 0.9.4 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/commands/sling.ts
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* 14. Return AgentSession
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { mkdirSync } from "node:fs";
|
|
22
21
|
import { mkdir } from "node:fs/promises";
|
|
23
22
|
import { join, resolve } from "node:path";
|
|
23
|
+
import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
|
|
24
24
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
25
25
|
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
26
26
|
import { writeOverlay } from "../agents/overlay.ts";
|
|
27
|
+
import { runTurn } from "../agents/turn-runner.ts";
|
|
27
28
|
import { createCanopyClient } from "../canopy/client.ts";
|
|
28
29
|
import { loadConfig } from "../config.ts";
|
|
29
30
|
import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
|
|
@@ -38,9 +39,8 @@ import { openSessionStore } from "../sessions/compat.ts";
|
|
|
38
39
|
import { createRunStore } from "../sessions/store.ts";
|
|
39
40
|
import type { TrackerIssue } from "../tracker/factory.ts";
|
|
40
41
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
41
|
-
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
42
|
+
import type { AgentSession, OverlayConfig, OverstoryConfig } from "../types.ts";
|
|
42
43
|
import { createWorktree, rollbackWorktree } from "../worktree/manager.ts";
|
|
43
|
-
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
44
44
|
import {
|
|
45
45
|
capturePaneContent,
|
|
46
46
|
checkSessionState,
|
|
@@ -156,6 +156,21 @@ export interface SlingOptions {
|
|
|
156
156
|
noScoutCheck?: boolean;
|
|
157
157
|
baseBranch?: string;
|
|
158
158
|
profile?: string;
|
|
159
|
+
headless?: boolean;
|
|
160
|
+
recover?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const WORKABLE_STATUSES = ["open", "in_progress"] as const;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decide whether a task with the given tracker status can accept a fresh
|
|
167
|
+
* sling. Normal dispatch requires an `open` or `in_progress` task; passing
|
|
168
|
+
* `recover` accepts any status so a coordinator can re-dispatch against a
|
|
169
|
+
* task whose previous owner exited (e.g. closed by a dead lead). (overstory-629f)
|
|
170
|
+
*/
|
|
171
|
+
export function isTaskWorkable(status: string, recover: boolean): boolean {
|
|
172
|
+
if (recover) return true;
|
|
173
|
+
return (WORKABLE_STATUSES as readonly string[]).includes(status);
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
export interface AutoDispatchOptions {
|
|
@@ -465,6 +480,44 @@ export async function getCurrentBranch(repoRoot: string): Promise<string | null>
|
|
|
465
480
|
return branch;
|
|
466
481
|
}
|
|
467
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Resolve whether to use the headless spawn path for a given runtime + flags + config.
|
|
485
|
+
*
|
|
486
|
+
* Precedence (highest first):
|
|
487
|
+
* 1. runtime.headless === true (statically headless runtimes always use headless)
|
|
488
|
+
* 2. Explicit --headless / --no-headless flag (boolean | undefined from commander)
|
|
489
|
+
* 3. config.runtime.claudeHeadlessByDefault (only applies when runtime.id === "claude")
|
|
490
|
+
* 4. Default: false (tmux)
|
|
491
|
+
*
|
|
492
|
+
* Throws ValidationError when --headless is explicitly true but the runtime has no
|
|
493
|
+
* buildDirectSpawn implementation.
|
|
494
|
+
*/
|
|
495
|
+
export function resolveUseHeadless(
|
|
496
|
+
runtime: { id: string; headless?: boolean; buildDirectSpawn?: unknown },
|
|
497
|
+
flag: boolean | undefined,
|
|
498
|
+
config: OverstoryConfig,
|
|
499
|
+
): boolean {
|
|
500
|
+
if (runtime.headless === true) return true;
|
|
501
|
+
|
|
502
|
+
if (flag === true) {
|
|
503
|
+
if (typeof runtime.buildDirectSpawn !== "function") {
|
|
504
|
+
throw new ValidationError(
|
|
505
|
+
`--headless requires a runtime with headless support. Runtime "${runtime.id}" does not implement buildDirectSpawn.`,
|
|
506
|
+
{ field: "headless", value: true },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (flag === false) return false;
|
|
512
|
+
|
|
513
|
+
if (runtime.id === "claude" && config.runtime?.claudeHeadlessByDefault === true) {
|
|
514
|
+
if (typeof runtime.buildDirectSpawn !== "function") return false;
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
468
521
|
/**
|
|
469
522
|
* Entry point for `ov sling <task-id> [flags]`.
|
|
470
523
|
*
|
|
@@ -490,6 +543,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
490
543
|
const forceHierarchy = opts.forceHierarchy ?? false;
|
|
491
544
|
const skipScout = opts.skipScout ?? false;
|
|
492
545
|
const skipTaskCheck = opts.skipTaskCheck ?? false;
|
|
546
|
+
const recover = opts.recover ?? false;
|
|
493
547
|
|
|
494
548
|
if (Number.isNaN(depth) || depth < 0) {
|
|
495
549
|
throw new ValidationError("--depth must be a non-negative integer", {
|
|
@@ -740,13 +794,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
740
794
|
});
|
|
741
795
|
}
|
|
742
796
|
|
|
743
|
-
|
|
744
|
-
if (!workableStatuses.includes(issue.status)) {
|
|
797
|
+
if (!isTaskWorkable(issue.status, recover)) {
|
|
745
798
|
throw new ValidationError(
|
|
746
|
-
`Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
|
|
799
|
+
`Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned. Pass --recover to re-dispatch against a closed task.`,
|
|
747
800
|
{ field: "taskId", value: taskId },
|
|
748
801
|
);
|
|
749
802
|
}
|
|
803
|
+
if (recover && !(WORKABLE_STATUSES as readonly string[]).includes(issue.status)) {
|
|
804
|
+
process.stderr.write(
|
|
805
|
+
`Warning: --recover dispatching against task "${taskId}" with status "${issue.status}". Previous owner may have exited unexpectedly.\n`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
750
808
|
}
|
|
751
809
|
|
|
752
810
|
// 7. Create worktree
|
|
@@ -846,12 +904,20 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
846
904
|
// 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
|
|
847
905
|
const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
|
|
848
906
|
|
|
849
|
-
// 9a.
|
|
907
|
+
// 9a. Resolve headless mode before deployConfig so hooks can be skipped for headless agents.
|
|
908
|
+
// resolveUseHeadless is also used at 11c for spawn routing — hoisted here to share the value.
|
|
909
|
+
const useHeadless = resolveUseHeadless(runtime, opts.headless, config);
|
|
910
|
+
|
|
911
|
+
// 9b. Deploy hooks config (capability-specific guards). In headless mode we deploy
|
|
912
|
+
// a PreToolUse-only subset (security guards) — overstory-e24b. Headless Claude Code
|
|
913
|
+
// dispatches settings.local.json hooks, so dropping them would leave destructive
|
|
914
|
+
// commands unblocked.
|
|
850
915
|
await runtime.deployConfig(worktreePath, undefined, {
|
|
851
916
|
agentName: name,
|
|
852
917
|
capability,
|
|
853
918
|
worktreePath,
|
|
854
919
|
qualityGates: config.project.qualityGates,
|
|
920
|
+
isHeadless: useHeadless,
|
|
855
921
|
});
|
|
856
922
|
|
|
857
923
|
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
@@ -919,42 +985,48 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
919
985
|
}
|
|
920
986
|
|
|
921
987
|
// 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
988
|
+
// useHeadless was resolved at step 9a (hoisted so deployConfig can skip hooks for headless).
|
|
989
|
+
if (useHeadless && runtime.buildDirectSpawn) {
|
|
990
|
+
// Phase 3 spawn-per-turn: headless agents have NO long-lived process.
|
|
991
|
+
// sling builds the initial prompt, upserts the session row in
|
|
992
|
+
// "booting", then drives the first user turn synchronously through
|
|
993
|
+
// `runTurn`. The runner spawns claude with `--resume` (when a prior
|
|
994
|
+
// session id exists), writes the prompt to a real stdin pipe, drains
|
|
995
|
+
// stream-json, captures session id, transitions state to "working"
|
|
996
|
+
// (or "completed" if terminal mail observed), and exits. No persistent
|
|
997
|
+
// process remains after this returns; subsequent turns are driven by
|
|
998
|
+
// `ov serve` (mail) or `ov nudge`.
|
|
999
|
+
const priorClaudeSessionId = store.getByName(name)?.claudeSessionId ?? null;
|
|
1000
|
+
|
|
1001
|
+
// Build the initial prompt (mulch expertise + pending mail + beacon)
|
|
1002
|
+
// as the first user turn.
|
|
1003
|
+
const pendingMailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
1004
|
+
let initialPrompt: string;
|
|
1005
|
+
try {
|
|
1006
|
+
const pendingMailClient = createMailClient(pendingMailStore);
|
|
1007
|
+
const pendingMessages = pendingMailClient.check(name);
|
|
1008
|
+
const mailSection = formatMailSection(pendingMessages);
|
|
1009
|
+
const beacon = buildBeacon({
|
|
1010
|
+
agentName: name,
|
|
1011
|
+
capability,
|
|
1012
|
+
taskId,
|
|
1013
|
+
parentAgent,
|
|
1014
|
+
depth,
|
|
1015
|
+
instructionPath: runtime.instructionPath,
|
|
1016
|
+
});
|
|
1017
|
+
initialPrompt = buildInitialHeadlessPrompt(
|
|
1018
|
+
mulchExpertise,
|
|
1019
|
+
mailSection || undefined,
|
|
1020
|
+
beacon,
|
|
1021
|
+
);
|
|
1022
|
+
} finally {
|
|
1023
|
+
pendingMailStore.close();
|
|
1024
|
+
}
|
|
956
1025
|
|
|
957
|
-
// 13. Record session
|
|
1026
|
+
// 13. Record session BEFORE runTurn so the runner reads it under its
|
|
1027
|
+
// lock. pid is null — there is no persistent process; the runner
|
|
1028
|
+
// publishes a per-turn PID via .overstory/agents/<name>/turn.pid for
|
|
1029
|
+
// the duration of each turn. Carry priorClaudeSessionId (mx-5c5ae6).
|
|
958
1030
|
const session: AgentSession = {
|
|
959
1031
|
id: `session-${Date.now()}-${name}`,
|
|
960
1032
|
agentName: name,
|
|
@@ -964,7 +1036,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
964
1036
|
taskId: taskId,
|
|
965
1037
|
tmuxSession: "",
|
|
966
1038
|
state: "booting",
|
|
967
|
-
pid:
|
|
1039
|
+
pid: null,
|
|
968
1040
|
parentAgent: parentAgent,
|
|
969
1041
|
depth,
|
|
970
1042
|
runId,
|
|
@@ -973,15 +1045,28 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
973
1045
|
escalationLevel: 0,
|
|
974
1046
|
stalledSince: null,
|
|
975
1047
|
transcriptPath: null,
|
|
1048
|
+
...(priorClaudeSessionId !== null ? { claudeSessionId: priorClaudeSessionId } : {}),
|
|
976
1049
|
};
|
|
977
1050
|
store.upsert(session);
|
|
978
1051
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1052
|
+
// Drive the first user turn synchronously. runTurn manages spawn,
|
|
1053
|
+
// stdin write+EOF, event drain, session_id capture, terminal-mail
|
|
1054
|
+
// detection, and state transition.
|
|
1055
|
+
const turnResult = await runTurn({
|
|
1056
|
+
agentName: name,
|
|
1057
|
+
capability,
|
|
1058
|
+
overstoryDir,
|
|
1059
|
+
worktreePath,
|
|
1060
|
+
projectRoot: config.project.root,
|
|
1061
|
+
taskId,
|
|
1062
|
+
userTurnNdjson: initialPrompt,
|
|
1063
|
+
runtime,
|
|
1064
|
+
resolvedModel,
|
|
1065
|
+
runId,
|
|
1066
|
+
mailDbPath: join(overstoryDir, "mail.db"),
|
|
1067
|
+
eventsDbPath: join(overstoryDir, "events.db"),
|
|
1068
|
+
sessionsDbPath: join(overstoryDir, "sessions.db"),
|
|
1069
|
+
});
|
|
985
1070
|
|
|
986
1071
|
// 14. Output result (headless)
|
|
987
1072
|
if (opts.json ?? false) {
|
|
@@ -992,14 +1077,19 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
992
1077
|
branch: branchName,
|
|
993
1078
|
worktree: worktreePath,
|
|
994
1079
|
tmuxSession: "",
|
|
995
|
-
pid:
|
|
1080
|
+
pid: null,
|
|
1081
|
+
initialTurnFinalState: turnResult.finalState,
|
|
1082
|
+
claudeSessionId: turnResult.newSessionId,
|
|
996
1083
|
});
|
|
997
1084
|
} else {
|
|
998
|
-
printSuccess("Agent launched (headless)", name);
|
|
999
|
-
process.stdout.write(` Task:
|
|
1000
|
-
process.stdout.write(` Branch:
|
|
1001
|
-
process.stdout.write(` Worktree:
|
|
1002
|
-
process.stdout.write(`
|
|
1085
|
+
printSuccess("Agent launched (headless, spawn-per-turn)", name);
|
|
1086
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1087
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1088
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1089
|
+
process.stdout.write(` First-turn state: ${turnResult.finalState}\n`);
|
|
1090
|
+
if (turnResult.newSessionId) {
|
|
1091
|
+
process.stdout.write(` Claude session id: ${turnResult.newSessionId}\n`);
|
|
1092
|
+
}
|
|
1003
1093
|
}
|
|
1004
1094
|
} else {
|
|
1005
1095
|
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
@@ -1054,14 +1144,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1054
1144
|
|
|
1055
1145
|
store.upsert(session);
|
|
1056
1146
|
|
|
1057
|
-
// Increment agent count for the run
|
|
1058
|
-
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
1059
|
-
try {
|
|
1060
|
-
runStore.incrementAgentCount(runId);
|
|
1061
|
-
} finally {
|
|
1062
|
-
runStore.close();
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
1147
|
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
1066
1148
|
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
1067
1149
|
if (shellDelay > 0) {
|
|
@@ -1076,7 +1158,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1076
1158
|
);
|
|
1077
1159
|
if (!tuiReady) {
|
|
1078
1160
|
const alive = await isSessionAlive(tmuxSessionName);
|
|
1079
|
-
|
|
1161
|
+
// Mark as zombie (not completed) so the watchdog detects this failed
|
|
1162
|
+
// startup. 'completed' is a terminal success state that the watchdog
|
|
1163
|
+
// skips entirely (overstory-c40e).
|
|
1164
|
+
store.updateState(name, "zombie");
|
|
1080
1165
|
|
|
1081
1166
|
if (alive) {
|
|
1082
1167
|
await killSession(tmuxSessionName);
|
|
@@ -51,6 +51,7 @@ function makeStatusData(overrides: Partial<StatusData> = {}): StatusData {
|
|
|
51
51
|
worktrees: [],
|
|
52
52
|
tmuxSessions: [{ name: "overstory-test-builder", pid: 12345 }],
|
|
53
53
|
unreadMailCount: 0,
|
|
54
|
+
unreadMailScope: "orchestrator",
|
|
54
55
|
mergeQueueCount: 0,
|
|
55
56
|
recentMetricsCount: 0,
|
|
56
57
|
...overrides,
|
|
@@ -90,6 +91,12 @@ describe("printStatus", () => {
|
|
|
90
91
|
expect(out).not.toContain("Mail sent:");
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
test("Mail line names the scope agent so per-agent scope is unambiguous", () => {
|
|
95
|
+
const data = makeStatusData({ unreadMailCount: 3, unreadMailScope: "lead-1" });
|
|
96
|
+
printStatus(data);
|
|
97
|
+
expect(stripAnsi(output())).toContain("Mail: 3 unread (to lead-1)");
|
|
98
|
+
});
|
|
99
|
+
|
|
93
100
|
test("verbose: shows worktree path, logs dir, and mail timestamps", () => {
|
|
94
101
|
const detail: VerboseAgentDetail = {
|
|
95
102
|
worktreePath: "/tmp/worktrees/test-builder",
|
|
@@ -208,6 +215,7 @@ describe("--verbose --json", () => {
|
|
|
208
215
|
worktrees: [],
|
|
209
216
|
tmuxSessions: [],
|
|
210
217
|
unreadMailCount: 0,
|
|
218
|
+
unreadMailScope: "orchestrator",
|
|
211
219
|
mergeQueueCount: 0,
|
|
212
220
|
recentMetricsCount: 0,
|
|
213
221
|
verboseDetails: { agent: detail },
|
|
@@ -226,6 +234,7 @@ describe("--verbose --json", () => {
|
|
|
226
234
|
worktrees: [],
|
|
227
235
|
tmuxSessions: [],
|
|
228
236
|
unreadMailCount: 0,
|
|
237
|
+
unreadMailScope: "orchestrator",
|
|
229
238
|
mergeQueueCount: 0,
|
|
230
239
|
recentMetricsCount: 0,
|
|
231
240
|
};
|
package/src/commands/status.ts
CHANGED
|
@@ -84,6 +84,7 @@ export interface StatusData {
|
|
|
84
84
|
worktrees: Array<{ path: string; branch: string; head: string }>;
|
|
85
85
|
tmuxSessions: Array<{ name: string; pid: number }>;
|
|
86
86
|
unreadMailCount: number;
|
|
87
|
+
unreadMailScope: string;
|
|
87
88
|
mergeQueueCount: number;
|
|
88
89
|
recentMetricsCount: number;
|
|
89
90
|
verboseDetails?: Record<string, VerboseAgentDetail>;
|
|
@@ -228,6 +229,7 @@ export async function gatherStatus(
|
|
|
228
229
|
worktrees,
|
|
229
230
|
tmuxSessions,
|
|
230
231
|
unreadMailCount,
|
|
232
|
+
unreadMailScope: agentName,
|
|
231
233
|
mergeQueueCount,
|
|
232
234
|
recentMetricsCount,
|
|
233
235
|
verboseDetails,
|
|
@@ -260,10 +262,14 @@ export function printStatus(data: StatusData): void {
|
|
|
260
262
|
? new Date(agent.lastActivity).getTime()
|
|
261
263
|
: now;
|
|
262
264
|
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
265
|
+
// See dashboard.ts for the three-topology liveness rationale (overstory-7a34).
|
|
263
266
|
const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
const isSpawnPerTurn = agent.tmuxSession === "" && agent.pid === null;
|
|
268
|
+
const alive = isSpawnPerTurn
|
|
269
|
+
? agent.state !== "zombie" && agent.state !== "completed"
|
|
270
|
+
: isHeadless
|
|
271
|
+
? agent.pid !== null && isProcessAlive(agent.pid)
|
|
272
|
+
: tmuxSessionNames.has(agent.tmuxSession);
|
|
267
273
|
const aliveMarker = alive ? color.green(">") : color.red("x");
|
|
268
274
|
w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
|
|
269
275
|
w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
|
|
@@ -293,7 +299,9 @@ export function printStatus(data: StatusData): void {
|
|
|
293
299
|
w("\n");
|
|
294
300
|
|
|
295
301
|
// Mail
|
|
296
|
-
|
|
302
|
+
// Scope is per-agent (the orchestrator by default). Differs from `ov mail list
|
|
303
|
+
// --unread` (system-wide) and `ov mail check` (per-agent, marks as read).
|
|
304
|
+
w(`Mail: ${data.unreadMailCount} unread (to ${data.unreadMailScope})\n`);
|
|
297
305
|
|
|
298
306
|
// Merge queue
|
|
299
307
|
w(`Merge queue: ${data.mergeQueueCount} pending\n`);
|
|
@@ -15,9 +15,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
15
15
|
import { mkdir, realpath } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { createMailClient } from "../mail/client.ts";
|
|
19
|
+
import { createMailStore } from "../mail/store.ts";
|
|
18
20
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
19
21
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
20
|
-
import type { AgentSession } from "../types.ts";
|
|
22
|
+
import type { AgentSession, MergeReadyPayload } from "../types.ts";
|
|
21
23
|
import { type StopDeps, stopCommand } from "./stop.ts";
|
|
22
24
|
|
|
23
25
|
// --- Fake Git (for branch deletion) ---
|
|
@@ -472,6 +474,177 @@ describe("stopCommand stop behavior", () => {
|
|
|
472
474
|
store.close();
|
|
473
475
|
expect(updated?.state).toBe("completed");
|
|
474
476
|
});
|
|
477
|
+
|
|
478
|
+
test("stopping a lead writes lead_completed pending-nudge for coordinator", async () => {
|
|
479
|
+
// Regression test for overstory-49a7:
|
|
480
|
+
// The lead_completed nudge now fires from `ov stop` (real completion signal),
|
|
481
|
+
// not from the per-turn Stop hook, which was spamming the coordinator.
|
|
482
|
+
const session = makeAgentSession({
|
|
483
|
+
agentName: "lead-alpha",
|
|
484
|
+
capability: "lead",
|
|
485
|
+
state: "working",
|
|
486
|
+
tmuxSession: "overstory-lead-alpha",
|
|
487
|
+
});
|
|
488
|
+
saveSessionsToDb([session]);
|
|
489
|
+
|
|
490
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
491
|
+
await stopCommand("lead-alpha", {}, deps);
|
|
492
|
+
|
|
493
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
494
|
+
const markerFile = Bun.file(markerPath);
|
|
495
|
+
expect(await markerFile.exists()).toBe(true);
|
|
496
|
+
|
|
497
|
+
const marker = JSON.parse(await markerFile.text());
|
|
498
|
+
expect(marker.from).toBe("lead-alpha");
|
|
499
|
+
expect(marker.reason).toBe("lead_completed");
|
|
500
|
+
expect(marker.subject).toContain("lead-alpha");
|
|
501
|
+
expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
|
|
502
|
+
expect(marker.createdAt).toBeDefined();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("lead exiting without merge_ready gets 'no merge_ready sent' subject (overstory-41fe)", async () => {
|
|
506
|
+
const session = makeAgentSession({
|
|
507
|
+
agentName: "lead-beta",
|
|
508
|
+
capability: "lead",
|
|
509
|
+
state: "working",
|
|
510
|
+
tmuxSession: "overstory-lead-beta",
|
|
511
|
+
});
|
|
512
|
+
saveSessionsToDb([session]);
|
|
513
|
+
|
|
514
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
515
|
+
await stopCommand("lead-beta", {}, deps);
|
|
516
|
+
|
|
517
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
518
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
519
|
+
expect(marker.subject).toBe(
|
|
520
|
+
"Lead lead-beta exited — no merge_ready sent, needs coordinator follow-up",
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("lead with one merge_ready gets branch-specific subject (overstory-41fe)", async () => {
|
|
525
|
+
const session = makeAgentSession({
|
|
526
|
+
agentName: "lead-gamma",
|
|
527
|
+
capability: "lead",
|
|
528
|
+
state: "working",
|
|
529
|
+
tmuxSession: "overstory-lead-gamma",
|
|
530
|
+
});
|
|
531
|
+
saveSessionsToDb([session]);
|
|
532
|
+
|
|
533
|
+
// Seed mail.db with a merge_ready message from this lead
|
|
534
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
535
|
+
const mailClient = createMailClient(mailStore);
|
|
536
|
+
const payload: MergeReadyPayload = {
|
|
537
|
+
branch: "overstory/lead-gamma/bead-42",
|
|
538
|
+
taskId: "bead-42",
|
|
539
|
+
agentName: "lead-gamma",
|
|
540
|
+
filesModified: ["src/foo.ts"],
|
|
541
|
+
};
|
|
542
|
+
mailClient.sendProtocol({
|
|
543
|
+
from: "lead-gamma",
|
|
544
|
+
to: "coordinator",
|
|
545
|
+
subject: "merge_ready: bead-42",
|
|
546
|
+
body: "ready",
|
|
547
|
+
type: "merge_ready",
|
|
548
|
+
payload,
|
|
549
|
+
});
|
|
550
|
+
mailClient.close();
|
|
551
|
+
|
|
552
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
553
|
+
await stopCommand("lead-gamma", {}, deps);
|
|
554
|
+
|
|
555
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
556
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
557
|
+
expect(marker.subject).toBe(
|
|
558
|
+
"Lead lead-gamma sent merge_ready for branch overstory/lead-gamma/bead-42",
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("lead with multiple merge_ready messages lists all unique branches (overstory-41fe)", async () => {
|
|
563
|
+
const session = makeAgentSession({
|
|
564
|
+
agentName: "lead-delta",
|
|
565
|
+
capability: "lead",
|
|
566
|
+
state: "working",
|
|
567
|
+
tmuxSession: "overstory-lead-delta",
|
|
568
|
+
});
|
|
569
|
+
saveSessionsToDb([session]);
|
|
570
|
+
|
|
571
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
572
|
+
const mailClient = createMailClient(mailStore);
|
|
573
|
+
for (const branch of ["overstory/worker-a/t1", "overstory/worker-b/t2"]) {
|
|
574
|
+
mailClient.sendProtocol({
|
|
575
|
+
from: "lead-delta",
|
|
576
|
+
to: "coordinator",
|
|
577
|
+
subject: `merge_ready: ${branch}`,
|
|
578
|
+
body: "ready",
|
|
579
|
+
type: "merge_ready",
|
|
580
|
+
payload: {
|
|
581
|
+
branch,
|
|
582
|
+
taskId: branch.split("/")[2] ?? "unknown",
|
|
583
|
+
agentName: "lead-delta",
|
|
584
|
+
filesModified: [],
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
mailClient.close();
|
|
589
|
+
|
|
590
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
591
|
+
await stopCommand("lead-delta", {}, deps);
|
|
592
|
+
|
|
593
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
594
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
595
|
+
expect(marker.subject).toContain("Lead lead-delta sent 2 merge_ready");
|
|
596
|
+
expect(marker.subject).toContain("overstory/worker-a/t1");
|
|
597
|
+
expect(marker.subject).toContain("overstory/worker-b/t2");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("merge_ready messages from other agents do not influence the subject (overstory-41fe)", async () => {
|
|
601
|
+
const session = makeAgentSession({
|
|
602
|
+
agentName: "lead-eps",
|
|
603
|
+
capability: "lead",
|
|
604
|
+
state: "working",
|
|
605
|
+
tmuxSession: "overstory-lead-eps",
|
|
606
|
+
});
|
|
607
|
+
saveSessionsToDb([session]);
|
|
608
|
+
|
|
609
|
+
// A *different* lead has merge_ready in the same mail.db — should be ignored
|
|
610
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
611
|
+
const mailClient = createMailClient(mailStore);
|
|
612
|
+
mailClient.sendProtocol({
|
|
613
|
+
from: "some-other-lead",
|
|
614
|
+
to: "coordinator",
|
|
615
|
+
subject: "merge_ready: x",
|
|
616
|
+
body: "ready",
|
|
617
|
+
type: "merge_ready",
|
|
618
|
+
payload: {
|
|
619
|
+
branch: "overstory/other/x",
|
|
620
|
+
taskId: "x",
|
|
621
|
+
agentName: "some-other-lead",
|
|
622
|
+
filesModified: [],
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
mailClient.close();
|
|
626
|
+
|
|
627
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
628
|
+
await stopCommand("lead-eps", {}, deps);
|
|
629
|
+
|
|
630
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
631
|
+
const marker = JSON.parse(await Bun.file(markerPath).text());
|
|
632
|
+
expect(marker.subject).toBe(
|
|
633
|
+
"Lead lead-eps exited — no merge_ready sent, needs coordinator follow-up",
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("stopping a non-lead agent does NOT write lead_completed pending-nudge", async () => {
|
|
638
|
+
const session = makeAgentSession({ state: "working", capability: "builder" });
|
|
639
|
+
saveSessionsToDb([session]);
|
|
640
|
+
|
|
641
|
+
const { deps } = makeDeps({ [session.tmuxSession]: true });
|
|
642
|
+
await stopCommand("my-builder", {}, deps);
|
|
643
|
+
|
|
644
|
+
const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
|
|
645
|
+
const markerFile = Bun.file(markerPath);
|
|
646
|
+
expect(await markerFile.exists()).toBe(false);
|
|
647
|
+
});
|
|
475
648
|
});
|
|
476
649
|
|
|
477
650
|
describe("stopCommand --json output", () => {
|