@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
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,75 @@ export interface SlingOptions {
|
|
|
156
156
|
noScoutCheck?: boolean;
|
|
157
157
|
baseBranch?: string;
|
|
158
158
|
profile?: string;
|
|
159
|
+
headless?: boolean;
|
|
160
|
+
recover?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Comma-separated list of sibling agent names dispatched in parallel that
|
|
163
|
+
* may share file scope with this agent (overstory-f76a). Plumbed through
|
|
164
|
+
* to `OverlayConfig.siblings` so the overlay renders rebase-before-merge_ready
|
|
165
|
+
* guidance.
|
|
166
|
+
*/
|
|
167
|
+
siblings?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse the `--siblings <names>` argument into a normalized string array.
|
|
172
|
+
* Trims whitespace, drops empty entries. Empty / undefined input → `[]`.
|
|
173
|
+
*
|
|
174
|
+
* Exported for unit-testing.
|
|
175
|
+
*/
|
|
176
|
+
export function parseSiblings(raw: string | undefined): string[] {
|
|
177
|
+
if (!raw) return [];
|
|
178
|
+
return raw
|
|
179
|
+
.split(",")
|
|
180
|
+
.map((s) => s.trim())
|
|
181
|
+
.filter((s) => s.length > 0);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const WORKABLE_STATUSES = ["open", "in_progress"] as const;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decide whether a task with the given tracker status can accept a fresh
|
|
188
|
+
* sling. Normal dispatch requires an `open` or `in_progress` task; passing
|
|
189
|
+
* `recover` accepts any status so a coordinator can re-dispatch against a
|
|
190
|
+
* task whose previous owner exited (e.g. closed by a dead lead). (overstory-629f)
|
|
191
|
+
*/
|
|
192
|
+
export function isTaskWorkable(status: string, recover: boolean): boolean {
|
|
193
|
+
if (recover) return true;
|
|
194
|
+
return (WORKABLE_STATUSES as readonly string[]).includes(status);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the effective `parentAgent` for a sling invocation, preserving the
|
|
199
|
+
* prior session's link on a re-spawn (`--recover`) when `--parent` was not
|
|
200
|
+
* explicitly passed.
|
|
201
|
+
*
|
|
202
|
+
* Pre-fix, sling always read `opts.parent ?? null` and upserted that into the
|
|
203
|
+
* session row, overwriting the prior `parent_agent` with null whenever a
|
|
204
|
+
* coordinator/lead invoked `ov sling --recover --name <existing>` without
|
|
205
|
+
* threading `--parent`. The runner then read `parentAgent === null` and
|
|
206
|
+
* skipped its in-band `worker_died` notify on a resumed-turn parser stall —
|
|
207
|
+
* the lead waited forever on a signal that never came (overstory-de3c).
|
|
208
|
+
*
|
|
209
|
+
* Resolution rules:
|
|
210
|
+
* - **Explicit caller intent wins.** If `opts.parent` is defined (including
|
|
211
|
+
* an empty string), use it verbatim. The caller may legitimately want to
|
|
212
|
+
* change or clear the parent on re-spawn.
|
|
213
|
+
* - **Caller silence preserves linkage.** If `opts.parent` is undefined and
|
|
214
|
+
* a prior session row exists with a non-null `parentAgent`, fall back to
|
|
215
|
+
* the prior value. Otherwise return null.
|
|
216
|
+
*
|
|
217
|
+
* Pure function so the regression test in `sling.test.ts` can assert behavior
|
|
218
|
+
* without spinning up the full sling command pipeline.
|
|
219
|
+
*/
|
|
220
|
+
export function resolveParentAgent(
|
|
221
|
+
optsParent: string | undefined,
|
|
222
|
+
existingSession: { parentAgent: string | null } | null,
|
|
223
|
+
): string | null {
|
|
224
|
+
if (optsParent !== undefined) {
|
|
225
|
+
return optsParent;
|
|
226
|
+
}
|
|
227
|
+
return existingSession?.parentAgent ?? null;
|
|
159
228
|
}
|
|
160
229
|
|
|
161
230
|
export interface AutoDispatchOptions {
|
|
@@ -164,6 +233,12 @@ export interface AutoDispatchOptions {
|
|
|
164
233
|
capability: string;
|
|
165
234
|
specPath: string | null;
|
|
166
235
|
parentAgent: string | null;
|
|
236
|
+
/**
|
|
237
|
+
* The agent who invoked `ov sling` (from `OVERSTORY_AGENT_NAME` env var);
|
|
238
|
+
* takes precedence over `parentAgent` for the mail `from` field, since
|
|
239
|
+
* `--parent` describes the new agent's hierarchical parent, not the slinger.
|
|
240
|
+
*/
|
|
241
|
+
slingerName: string | null;
|
|
167
242
|
instructionPath: string;
|
|
168
243
|
}
|
|
169
244
|
|
|
@@ -180,7 +255,7 @@ export function buildAutoDispatch(opts: AutoDispatchOptions): {
|
|
|
180
255
|
subject: string;
|
|
181
256
|
body: string;
|
|
182
257
|
} {
|
|
183
|
-
const from = opts.parentAgent ?? "orchestrator";
|
|
258
|
+
const from = opts.slingerName ?? opts.parentAgent ?? "orchestrator";
|
|
184
259
|
const specLine = opts.specPath
|
|
185
260
|
? `Spec file: ${opts.specPath}`
|
|
186
261
|
: "No spec file provided. Check your overlay for task details.";
|
|
@@ -465,6 +540,44 @@ export async function getCurrentBranch(repoRoot: string): Promise<string | null>
|
|
|
465
540
|
return branch;
|
|
466
541
|
}
|
|
467
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Resolve whether to use the headless spawn path for a given runtime + flags + config.
|
|
545
|
+
*
|
|
546
|
+
* Precedence (highest first):
|
|
547
|
+
* 1. runtime.headless === true (statically headless runtimes always use headless)
|
|
548
|
+
* 2. Explicit --headless / --no-headless flag (boolean | undefined from commander)
|
|
549
|
+
* 3. config.runtime.claudeHeadlessByDefault (only applies when runtime.id === "claude")
|
|
550
|
+
* 4. Default: false (tmux)
|
|
551
|
+
*
|
|
552
|
+
* Throws ValidationError when --headless is explicitly true but the runtime has no
|
|
553
|
+
* buildDirectSpawn implementation.
|
|
554
|
+
*/
|
|
555
|
+
export function resolveUseHeadless(
|
|
556
|
+
runtime: { id: string; headless?: boolean; buildDirectSpawn?: unknown },
|
|
557
|
+
flag: boolean | undefined,
|
|
558
|
+
config: OverstoryConfig,
|
|
559
|
+
): boolean {
|
|
560
|
+
if (runtime.headless === true) return true;
|
|
561
|
+
|
|
562
|
+
if (flag === true) {
|
|
563
|
+
if (typeof runtime.buildDirectSpawn !== "function") {
|
|
564
|
+
throw new ValidationError(
|
|
565
|
+
`--headless requires a runtime with headless support. Runtime "${runtime.id}" does not implement buildDirectSpawn.`,
|
|
566
|
+
{ field: "headless", value: true },
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
if (flag === false) return false;
|
|
572
|
+
|
|
573
|
+
if (runtime.id === "claude" && config.runtime?.claudeHeadlessByDefault === true) {
|
|
574
|
+
if (typeof runtime.buildDirectSpawn !== "function") return false;
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
468
581
|
/**
|
|
469
582
|
* Entry point for `ov sling <task-id> [flags]`.
|
|
470
583
|
*
|
|
@@ -484,12 +597,15 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
484
597
|
let name = nameWasAutoGenerated ? `${capability}-${taskId}` : rawName;
|
|
485
598
|
const specPath = opts.spec ?? null;
|
|
486
599
|
const filesRaw = opts.files;
|
|
487
|
-
|
|
600
|
+
// Reassigned later when re-spawning an existing agent to preserve the prior
|
|
601
|
+
// row's parentAgent — see overstory-de3c at the existingSession lookup below.
|
|
602
|
+
let parentAgent = opts.parent ?? null;
|
|
488
603
|
const depthStr = opts.depth;
|
|
489
604
|
const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
|
|
490
605
|
const forceHierarchy = opts.forceHierarchy ?? false;
|
|
491
606
|
const skipScout = opts.skipScout ?? false;
|
|
492
607
|
const skipTaskCheck = opts.skipTaskCheck ?? false;
|
|
608
|
+
const recover = opts.recover ?? false;
|
|
493
609
|
|
|
494
610
|
if (Number.isNaN(depth) || depth < 0) {
|
|
495
611
|
throw new ValidationError("--depth must be a non-negative integer", {
|
|
@@ -560,6 +676,8 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
560
676
|
.filter((f) => f.length > 0)
|
|
561
677
|
: [];
|
|
562
678
|
|
|
679
|
+
const siblings = parseSiblings(opts.siblings);
|
|
680
|
+
|
|
563
681
|
// 1. Load config
|
|
564
682
|
const cwd = process.cwd();
|
|
565
683
|
const config = await loadConfig(cwd);
|
|
@@ -661,18 +779,35 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
661
779
|
);
|
|
662
780
|
}
|
|
663
781
|
|
|
782
|
+
// Track the prior session row when re-spawning against an existing agent
|
|
783
|
+
// name so downstream code can preserve linkage (parentAgent, claudeSessionId)
|
|
784
|
+
// that the upsert would otherwise erase. Auto-generated names are unique
|
|
785
|
+
// so there is never a prior row to preserve.
|
|
786
|
+
let existingSession: AgentSession | null = null;
|
|
664
787
|
if (nameWasAutoGenerated) {
|
|
665
788
|
const takenNames = activeSessions.map((s) => s.agentName);
|
|
666
789
|
name = generateAgentName(capability, taskId, takenNames);
|
|
667
790
|
} else {
|
|
668
|
-
|
|
669
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
791
|
+
existingSession = store.getByName(name);
|
|
792
|
+
if (
|
|
793
|
+
existingSession &&
|
|
794
|
+
existingSession.state !== "zombie" &&
|
|
795
|
+
existingSession.state !== "completed"
|
|
796
|
+
) {
|
|
797
|
+
throw new AgentError(
|
|
798
|
+
`Agent name "${name}" is already in use (state: ${existingSession.state})`,
|
|
799
|
+
{
|
|
800
|
+
agentName: name,
|
|
801
|
+
},
|
|
802
|
+
);
|
|
673
803
|
}
|
|
674
804
|
}
|
|
675
805
|
|
|
806
|
+
// Preserve the prior session's parentAgent on re-spawn when --parent was
|
|
807
|
+
// not explicitly passed (overstory-de3c). See `resolveParentAgent` for the
|
|
808
|
+
// full rationale and resolution rules.
|
|
809
|
+
parentAgent = resolveParentAgent(opts.parent, existingSession);
|
|
810
|
+
|
|
676
811
|
// 5d. Task-level locking: prevent concurrent agents on the same task ID.
|
|
677
812
|
// Exception: the parent agent may delegate its own task to a child.
|
|
678
813
|
const lockHolder = checkTaskLock(activeSessions, taskId);
|
|
@@ -740,13 +875,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
740
875
|
});
|
|
741
876
|
}
|
|
742
877
|
|
|
743
|
-
|
|
744
|
-
if (!workableStatuses.includes(issue.status)) {
|
|
878
|
+
if (!isTaskWorkable(issue.status, recover)) {
|
|
745
879
|
throw new ValidationError(
|
|
746
|
-
`Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
|
|
880
|
+
`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
881
|
{ field: "taskId", value: taskId },
|
|
748
882
|
);
|
|
749
883
|
}
|
|
884
|
+
if (recover && !(WORKABLE_STATUSES as readonly string[]).includes(issue.status)) {
|
|
885
|
+
process.stderr.write(
|
|
886
|
+
`Warning: --recover dispatching against task "${taskId}" with status "${issue.status}". Previous owner may have exited unexpectedly.\n`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
750
889
|
}
|
|
751
890
|
|
|
752
891
|
// 7. Create worktree
|
|
@@ -839,6 +978,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
839
978
|
trackerCli: trackerCliName(resolvedBackend),
|
|
840
979
|
trackerName: resolvedBackend,
|
|
841
980
|
instructionPath: runtime.instructionPath,
|
|
981
|
+
siblings,
|
|
842
982
|
};
|
|
843
983
|
|
|
844
984
|
await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
|
|
@@ -846,22 +986,32 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
846
986
|
// 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
|
|
847
987
|
const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
|
|
848
988
|
|
|
849
|
-
// 9a.
|
|
989
|
+
// 9a. Resolve headless mode before deployConfig so hooks can be skipped for headless agents.
|
|
990
|
+
// resolveUseHeadless is also used at 11c for spawn routing — hoisted here to share the value.
|
|
991
|
+
const useHeadless = resolveUseHeadless(runtime, opts.headless, config);
|
|
992
|
+
|
|
993
|
+
// 9b. Deploy hooks config (capability-specific guards). In headless mode we deploy
|
|
994
|
+
// a PreToolUse-only subset (security guards) — overstory-e24b. Headless Claude Code
|
|
995
|
+
// dispatches settings.local.json hooks, so dropping them would leave destructive
|
|
996
|
+
// commands unblocked.
|
|
850
997
|
await runtime.deployConfig(worktreePath, undefined, {
|
|
851
998
|
agentName: name,
|
|
852
999
|
capability,
|
|
853
1000
|
worktreePath,
|
|
854
1001
|
qualityGates: config.project.qualityGates,
|
|
1002
|
+
isHeadless: useHeadless,
|
|
855
1003
|
});
|
|
856
1004
|
|
|
857
1005
|
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
858
1006
|
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
1007
|
+
const slingerName = process.env.OVERSTORY_AGENT_NAME?.trim() || null;
|
|
859
1008
|
const dispatch = buildAutoDispatch({
|
|
860
1009
|
agentName: name,
|
|
861
1010
|
taskId,
|
|
862
1011
|
capability,
|
|
863
1012
|
specPath: absoluteSpecPath,
|
|
864
1013
|
parentAgent,
|
|
1014
|
+
slingerName,
|
|
865
1015
|
instructionPath: runtime.instructionPath,
|
|
866
1016
|
});
|
|
867
1017
|
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
@@ -919,42 +1069,51 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
919
1069
|
}
|
|
920
1070
|
|
|
921
1071
|
// 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
|
-
|
|
1072
|
+
// useHeadless was resolved at step 9a (hoisted so deployConfig can skip hooks for headless).
|
|
1073
|
+
if (useHeadless && runtime.buildDirectSpawn) {
|
|
1074
|
+
// Phase 3 spawn-per-turn: headless agents have NO long-lived process.
|
|
1075
|
+
// sling builds the initial prompt, upserts the session row in
|
|
1076
|
+
// "booting", then drives the first user turn synchronously through
|
|
1077
|
+
// `runTurn`. The runner spawns claude with `--resume` (when a prior
|
|
1078
|
+
// session id exists), writes the prompt to a real stdin pipe, drains
|
|
1079
|
+
// stream-json, captures session id, transitions state to "working"
|
|
1080
|
+
// (or "completed" if terminal mail observed), and exits. No persistent
|
|
1081
|
+
// process remains after this returns; subsequent turns are driven by
|
|
1082
|
+
// `ov serve` (mail) or `ov nudge`.
|
|
1083
|
+
// `existingSession` was captured during the name-collision check (above).
|
|
1084
|
+
// Re-using it here keeps re-spawn linkage (parentAgent + claudeSessionId)
|
|
1085
|
+
// resolved from the same row.
|
|
1086
|
+
const priorClaudeSessionId = existingSession?.claudeSessionId ?? null;
|
|
1087
|
+
|
|
1088
|
+
// Build the initial prompt (mulch expertise + pending mail + beacon)
|
|
1089
|
+
// as the first user turn.
|
|
1090
|
+
const pendingMailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
1091
|
+
let initialPrompt: string;
|
|
1092
|
+
try {
|
|
1093
|
+
const pendingMailClient = createMailClient(pendingMailStore);
|
|
1094
|
+
const pendingMessages = pendingMailClient.check(name);
|
|
1095
|
+
const mailSection = formatMailSection(pendingMessages);
|
|
1096
|
+
const beacon = buildBeacon({
|
|
1097
|
+
agentName: name,
|
|
1098
|
+
capability,
|
|
1099
|
+
taskId,
|
|
1100
|
+
parentAgent,
|
|
1101
|
+
depth,
|
|
1102
|
+
instructionPath: runtime.instructionPath,
|
|
1103
|
+
});
|
|
1104
|
+
initialPrompt = buildInitialHeadlessPrompt(
|
|
1105
|
+
mulchExpertise,
|
|
1106
|
+
mailSection || undefined,
|
|
1107
|
+
beacon,
|
|
1108
|
+
);
|
|
1109
|
+
} finally {
|
|
1110
|
+
pendingMailStore.close();
|
|
1111
|
+
}
|
|
956
1112
|
|
|
957
|
-
// 13. Record session
|
|
1113
|
+
// 13. Record session BEFORE runTurn so the runner reads it under its
|
|
1114
|
+
// lock. pid is null — there is no persistent process; the runner
|
|
1115
|
+
// publishes a per-turn PID via .overstory/agents/<name>/turn.pid for
|
|
1116
|
+
// the duration of each turn. Carry priorClaudeSessionId (mx-5c5ae6).
|
|
958
1117
|
const session: AgentSession = {
|
|
959
1118
|
id: `session-${Date.now()}-${name}`,
|
|
960
1119
|
agentName: name,
|
|
@@ -964,7 +1123,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
964
1123
|
taskId: taskId,
|
|
965
1124
|
tmuxSession: "",
|
|
966
1125
|
state: "booting",
|
|
967
|
-
pid:
|
|
1126
|
+
pid: null,
|
|
968
1127
|
parentAgent: parentAgent,
|
|
969
1128
|
depth,
|
|
970
1129
|
runId,
|
|
@@ -973,15 +1132,28 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
973
1132
|
escalationLevel: 0,
|
|
974
1133
|
stalledSince: null,
|
|
975
1134
|
transcriptPath: null,
|
|
1135
|
+
...(priorClaudeSessionId !== null ? { claudeSessionId: priorClaudeSessionId } : {}),
|
|
976
1136
|
};
|
|
977
1137
|
store.upsert(session);
|
|
978
1138
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1139
|
+
// Drive the first user turn synchronously. runTurn manages spawn,
|
|
1140
|
+
// stdin write+EOF, event drain, session_id capture, terminal-mail
|
|
1141
|
+
// detection, and state transition.
|
|
1142
|
+
const turnResult = await runTurn({
|
|
1143
|
+
agentName: name,
|
|
1144
|
+
capability,
|
|
1145
|
+
overstoryDir,
|
|
1146
|
+
worktreePath,
|
|
1147
|
+
projectRoot: config.project.root,
|
|
1148
|
+
taskId,
|
|
1149
|
+
userTurnNdjson: initialPrompt,
|
|
1150
|
+
runtime,
|
|
1151
|
+
resolvedModel,
|
|
1152
|
+
runId,
|
|
1153
|
+
mailDbPath: join(overstoryDir, "mail.db"),
|
|
1154
|
+
eventsDbPath: join(overstoryDir, "events.db"),
|
|
1155
|
+
sessionsDbPath: join(overstoryDir, "sessions.db"),
|
|
1156
|
+
});
|
|
985
1157
|
|
|
986
1158
|
// 14. Output result (headless)
|
|
987
1159
|
if (opts.json ?? false) {
|
|
@@ -992,14 +1164,19 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
992
1164
|
branch: branchName,
|
|
993
1165
|
worktree: worktreePath,
|
|
994
1166
|
tmuxSession: "",
|
|
995
|
-
pid:
|
|
1167
|
+
pid: null,
|
|
1168
|
+
initialTurnFinalState: turnResult.finalState,
|
|
1169
|
+
claudeSessionId: turnResult.newSessionId,
|
|
996
1170
|
});
|
|
997
1171
|
} 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(`
|
|
1172
|
+
printSuccess("Agent launched (headless, spawn-per-turn)", name);
|
|
1173
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1174
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1175
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1176
|
+
process.stdout.write(` First-turn state: ${turnResult.finalState}\n`);
|
|
1177
|
+
if (turnResult.newSessionId) {
|
|
1178
|
+
process.stdout.write(` Claude session id: ${turnResult.newSessionId}\n`);
|
|
1179
|
+
}
|
|
1003
1180
|
}
|
|
1004
1181
|
} else {
|
|
1005
1182
|
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
@@ -1054,14 +1231,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1054
1231
|
|
|
1055
1232
|
store.upsert(session);
|
|
1056
1233
|
|
|
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
1234
|
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
1066
1235
|
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
1067
1236
|
if (shellDelay > 0) {
|
|
@@ -1076,7 +1245,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
1076
1245
|
);
|
|
1077
1246
|
if (!tuiReady) {
|
|
1078
1247
|
const alive = await isSessionAlive(tmuxSessionName);
|
|
1079
|
-
|
|
1248
|
+
// Mark as zombie (not completed) so the watchdog detects this failed
|
|
1249
|
+
// startup. 'completed' is a terminal success state that the watchdog
|
|
1250
|
+
// skips entirely (overstory-c40e).
|
|
1251
|
+
store.updateState(name, "zombie");
|
|
1080
1252
|
|
|
1081
1253
|
if (alive) {
|
|
1082
1254
|
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`);
|