@os-eco/overstory-cli 0.6.10 → 0.6.11
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 +156 -272
- package/agents/lead.md +9 -0
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +53 -0
- package/src/agents/hooks-deployer.ts +4 -4
- package/src/agents/manifest.test.ts +1 -0
- package/src/agents/overlay.test.ts +79 -0
- package/src/agents/overlay.ts +40 -2
- package/src/commands/completions.ts +3 -3
- package/src/commands/coordinator.ts +4 -4
- package/src/commands/group.ts +4 -4
- package/src/commands/mail.test.ts +63 -1
- package/src/commands/mail.ts +18 -1
- package/src/commands/merge.ts +2 -2
- package/src/commands/monitor.ts +2 -2
- package/src/commands/sling.test.ts +174 -27
- package/src/commands/sling.ts +95 -11
- package/src/commands/status.ts +1 -1
- package/src/commands/supervisor.ts +4 -4
- package/src/commands/trace.ts +2 -2
- package/src/config.test.ts +22 -0
- package/src/config.ts +12 -0
- package/src/doctor/agents.test.ts +1 -0
- package/src/doctor/config-check.test.ts +1 -0
- package/src/doctor/consistency.test.ts +1 -0
- package/src/doctor/databases.test.ts +1 -0
- package/src/doctor/dependencies.test.ts +1 -0
- package/src/doctor/ecosystem.test.ts +1 -0
- package/src/doctor/logs.test.ts +1 -0
- package/src/doctor/merge-queue.test.ts +1 -0
- package/src/doctor/structure.test.ts +1 -0
- package/src/doctor/version.test.ts +1 -0
- package/src/index.ts +7 -4
- package/src/types.ts +11 -0
- package/templates/overlay.md.tmpl +3 -1
|
@@ -495,6 +495,85 @@ describe("generateOverlay", () => {
|
|
|
495
495
|
|
|
496
496
|
expect(output).toContain("bd close overstory-back");
|
|
497
497
|
});
|
|
498
|
+
|
|
499
|
+
test("dispatch overrides: skipReview injects SKIP REVIEW directive for leads", async () => {
|
|
500
|
+
const config = makeConfig({
|
|
501
|
+
capability: "lead",
|
|
502
|
+
skipReview: true,
|
|
503
|
+
canSpawn: true,
|
|
504
|
+
});
|
|
505
|
+
const output = await generateOverlay(config);
|
|
506
|
+
|
|
507
|
+
expect(output).toContain("Dispatch Overrides");
|
|
508
|
+
expect(output).toContain("SKIP REVIEW");
|
|
509
|
+
expect(output).toContain("Self-verify");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("dispatch overrides: maxAgentsOverride injects MAX AGENTS directive for leads", async () => {
|
|
513
|
+
const config = makeConfig({
|
|
514
|
+
capability: "lead",
|
|
515
|
+
maxAgentsOverride: 3,
|
|
516
|
+
canSpawn: true,
|
|
517
|
+
});
|
|
518
|
+
const output = await generateOverlay(config);
|
|
519
|
+
|
|
520
|
+
expect(output).toContain("Dispatch Overrides");
|
|
521
|
+
expect(output).toContain("MAX AGENTS");
|
|
522
|
+
expect(output).toContain("3");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("dispatch overrides: both skipReview and maxAgentsOverride together", async () => {
|
|
526
|
+
const config = makeConfig({
|
|
527
|
+
capability: "lead",
|
|
528
|
+
skipReview: true,
|
|
529
|
+
maxAgentsOverride: 4,
|
|
530
|
+
canSpawn: true,
|
|
531
|
+
});
|
|
532
|
+
const output = await generateOverlay(config);
|
|
533
|
+
|
|
534
|
+
expect(output).toContain("SKIP REVIEW");
|
|
535
|
+
expect(output).toContain("MAX AGENTS");
|
|
536
|
+
expect(output).toContain("4");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("dispatch overrides: not injected for builder capability", async () => {
|
|
540
|
+
const config = makeConfig({
|
|
541
|
+
capability: "builder",
|
|
542
|
+
skipReview: true,
|
|
543
|
+
maxAgentsOverride: 3,
|
|
544
|
+
});
|
|
545
|
+
const output = await generateOverlay(config);
|
|
546
|
+
|
|
547
|
+
expect(output).not.toContain("Dispatch Overrides");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("dispatch overrides: not injected when no overrides set", async () => {
|
|
551
|
+
const config = makeConfig({
|
|
552
|
+
capability: "lead",
|
|
553
|
+
canSpawn: true,
|
|
554
|
+
});
|
|
555
|
+
const output = await generateOverlay(config);
|
|
556
|
+
|
|
557
|
+
expect(output).not.toContain("Dispatch Overrides");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("dispatch overrides: maxAgentsOverride of 0 is not injected", async () => {
|
|
561
|
+
const config = makeConfig({
|
|
562
|
+
capability: "lead",
|
|
563
|
+
maxAgentsOverride: 0,
|
|
564
|
+
canSpawn: true,
|
|
565
|
+
});
|
|
566
|
+
const output = await generateOverlay(config);
|
|
567
|
+
|
|
568
|
+
expect(output).not.toContain("MAX AGENTS");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("no unreplaced DISPATCH_OVERRIDES placeholder", async () => {
|
|
572
|
+
const config = makeConfig();
|
|
573
|
+
const output = await generateOverlay(config);
|
|
574
|
+
|
|
575
|
+
expect(output).not.toContain("{{DISPATCH_OVERRIDES}}");
|
|
576
|
+
});
|
|
498
577
|
});
|
|
499
578
|
|
|
500
579
|
describe("writeOverlay", () => {
|
package/src/agents/overlay.ts
CHANGED
|
@@ -72,6 +72,43 @@ Do NOT spawn scout agents. Do NOT explore the codebase extensively.
|
|
|
72
72
|
Your parent has already gathered the context you need.
|
|
73
73
|
`;
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Build the dispatch overrides section for lead overlays.
|
|
77
|
+
* Only generates content when overrides are actually set.
|
|
78
|
+
* The overlay is the source of truth -- leads read these directives, not mail.
|
|
79
|
+
*/
|
|
80
|
+
function formatDispatchOverrides(config: OverlayConfig): string {
|
|
81
|
+
if (config.capability !== "lead") return "";
|
|
82
|
+
|
|
83
|
+
const sections: string[] = [];
|
|
84
|
+
|
|
85
|
+
if (config.skipReview) {
|
|
86
|
+
sections.push(
|
|
87
|
+
"- **SKIP REVIEW**: You have been instructed to skip the review phase. " +
|
|
88
|
+
"Self-verify by reading the diff and running quality gates instead of spawning a reviewer.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (config.maxAgentsOverride !== undefined && config.maxAgentsOverride > 0) {
|
|
93
|
+
sections.push(
|
|
94
|
+
`- **MAX AGENTS**: Your per-lead agent ceiling has been set to **${config.maxAgentsOverride}**. ` +
|
|
95
|
+
"Do not spawn more than this many sub-workers.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (sections.length === 0) return "";
|
|
100
|
+
|
|
101
|
+
return [
|
|
102
|
+
"## Dispatch Overrides",
|
|
103
|
+
"",
|
|
104
|
+
"Your coordinator has set the following overrides for this work stream:",
|
|
105
|
+
"",
|
|
106
|
+
...sections,
|
|
107
|
+
"",
|
|
108
|
+
"Honor these directives. They override the default workflow described in your base definition.",
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
/**
|
|
76
113
|
* Format the quality gates section. Read-only agents (scout, reviewer) get
|
|
77
114
|
* a lightweight section that only tells them to close the issue and report.
|
|
@@ -261,7 +298,7 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
|
|
|
261
298
|
|
|
262
299
|
const replacements: Record<string, string> = {
|
|
263
300
|
"{{AGENT_NAME}}": config.agentName,
|
|
264
|
-
"{{
|
|
301
|
+
"{{TASK_ID}}": config.taskId,
|
|
265
302
|
"{{SPEC_PATH}}": config.specPath ?? "No spec file provided",
|
|
266
303
|
"{{BRANCH_NAME}}": config.branchName,
|
|
267
304
|
"{{WORKTREE_PATH}}": config.worktreePath,
|
|
@@ -275,13 +312,14 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
|
|
|
275
312
|
"{{CONSTRAINTS}}": formatConstraints(config),
|
|
276
313
|
"{{SPEC_INSTRUCTION}}": specInstruction,
|
|
277
314
|
"{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
|
|
315
|
+
"{{DISPATCH_OVERRIDES}}": formatDispatchOverrides(config),
|
|
278
316
|
"{{BASE_DEFINITION}}": config.baseDefinition,
|
|
279
317
|
"{{QUALITY_GATE_INLINE}}": formatQualityGatesInline(config.qualityGates),
|
|
280
318
|
"{{QUALITY_GATE_STEPS}}": formatQualityGatesSteps(config.qualityGates),
|
|
281
319
|
"{{QUALITY_GATE_BASH}}": formatQualityGatesBash(config.qualityGates),
|
|
282
320
|
"{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
|
|
283
321
|
"{{TRACKER_CLI}}": config.trackerCli ?? "bd",
|
|
284
|
-
"{{TRACKER_NAME}}": config.trackerName ?? "
|
|
322
|
+
"{{TRACKER_NAME}}": config.trackerName ?? "seeds",
|
|
285
323
|
};
|
|
286
324
|
|
|
287
325
|
let result = template;
|
|
@@ -236,7 +236,7 @@ export const COMMANDS: readonly CommandDef[] = [
|
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
name: "trace",
|
|
239
|
-
desc: "Chronological event timeline for agent
|
|
239
|
+
desc: "Chronological event timeline for agent or task",
|
|
240
240
|
flags: [
|
|
241
241
|
{ name: "--json", desc: "JSON output" },
|
|
242
242
|
{ name: "--since", desc: "Time range filter (ISO 8601)", takesValue: true },
|
|
@@ -366,7 +366,7 @@ export const COMMANDS: readonly CommandDef[] = [
|
|
|
366
366
|
name: "start",
|
|
367
367
|
desc: "Start supervisor",
|
|
368
368
|
flags: [
|
|
369
|
-
{ name: "--task", desc: "
|
|
369
|
+
{ name: "--task", desc: "Task ID", takesValue: true },
|
|
370
370
|
{ name: "--name", desc: "Unique name", takesValue: true },
|
|
371
371
|
{ name: "--parent", desc: "Parent agent", takesValue: true },
|
|
372
372
|
{ name: "--depth", desc: "Hierarchy depth", takesValue: true },
|
|
@@ -543,7 +543,7 @@ export const COMMANDS: readonly CommandDef[] = [
|
|
|
543
543
|
desc: "Task groups",
|
|
544
544
|
flags: [
|
|
545
545
|
{ name: "--json", desc: "JSON output" },
|
|
546
|
-
{ name: "--skip-validation", desc: "Skip
|
|
546
|
+
{ name: "--skip-validation", desc: "Skip task validation" },
|
|
547
547
|
{ name: "--help", desc: "Show help" },
|
|
548
548
|
],
|
|
549
549
|
subcommands: [
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* CLI command: ov coordinator start|stop|status
|
|
3
3
|
*
|
|
4
4
|
* Manages the persistent coordinator agent lifecycle. The coordinator runs
|
|
5
|
-
* at the project root (NOT in a worktree), receives work via mail and
|
|
5
|
+
* at the project root (NOT in a worktree), receives work via mail and tasks,
|
|
6
6
|
* and dispatches agents via ov sling.
|
|
7
7
|
*
|
|
8
8
|
* Unlike regular agents spawned by sling, the coordinator:
|
|
9
9
|
* - Has no worktree (operates on the main working tree)
|
|
10
|
-
* - Has no
|
|
11
|
-
* - Has no overlay CLAUDE.md (context comes via mail +
|
|
10
|
+
* - Has no task assignment (it creates tasks, not works on them)
|
|
11
|
+
* - Has no overlay CLAUDE.md (context comes via mail + tasks + checkpoints)
|
|
12
12
|
* - Persists across work batches
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -381,7 +381,7 @@ async function startCoordinator(
|
|
|
381
381
|
capability: "coordinator",
|
|
382
382
|
worktreePath: projectRoot, // Coordinator uses project root, not a worktree
|
|
383
383
|
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
384
|
-
taskId: "", // No specific
|
|
384
|
+
taskId: "", // No specific task assignment
|
|
385
385
|
tmuxSession,
|
|
386
386
|
state: "booting",
|
|
387
387
|
pid,
|
package/src/commands/group.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* CLI command: ov group create|status|add|remove|list
|
|
3
3
|
*
|
|
4
4
|
* Manages TaskGroups for batch work coordination. Groups track collections
|
|
5
|
-
* of
|
|
5
|
+
* of issues and auto-close when all member issues are closed.
|
|
6
6
|
*
|
|
7
7
|
* Storage: `.overstory/groups.json` (array of TaskGroup objects).
|
|
8
8
|
*/
|
|
@@ -312,7 +312,7 @@ export function createGroupCommand(): Command {
|
|
|
312
312
|
.argument("<name>", "Group name")
|
|
313
313
|
.argument("<ids...>", "Issue IDs to include")
|
|
314
314
|
.option("--json", "Output as JSON")
|
|
315
|
-
.option("--skip-validation", "Skip
|
|
315
|
+
.option("--skip-validation", "Skip task validation (for offline use)")
|
|
316
316
|
.action(
|
|
317
317
|
async (name: string, ids: string[], opts: { json?: boolean; skipValidation?: boolean }) => {
|
|
318
318
|
const config = await loadConfig(process.cwd());
|
|
@@ -343,7 +343,7 @@ export function createGroupCommand(): Command {
|
|
|
343
343
|
.description("Show progress for one or all groups")
|
|
344
344
|
.argument("[group-id]", "Group ID (optional, shows all if omitted)")
|
|
345
345
|
.option("--json", "Output as JSON")
|
|
346
|
-
.option("--skip-validation", "Skip
|
|
346
|
+
.option("--skip-validation", "Skip task validation (for offline use)")
|
|
347
347
|
.action(
|
|
348
348
|
async (groupId: string | undefined, opts: { json?: boolean; skipValidation?: boolean }) => {
|
|
349
349
|
const config = await loadConfig(process.cwd());
|
|
@@ -398,7 +398,7 @@ export function createGroupCommand(): Command {
|
|
|
398
398
|
.argument("<group-id>", "Group ID")
|
|
399
399
|
.argument("<ids...>", "Issue IDs to add")
|
|
400
400
|
.option("--json", "Output as JSON")
|
|
401
|
-
.option("--skip-validation", "Skip
|
|
401
|
+
.option("--skip-validation", "Skip task validation (for offline use)")
|
|
402
402
|
.action(
|
|
403
403
|
async (
|
|
404
404
|
groupId: string,
|
|
@@ -14,7 +14,7 @@ import { stripAnsi } from "../logging/color.ts";
|
|
|
14
14
|
import { createMailClient } from "../mail/client.ts";
|
|
15
15
|
import { createMailStore } from "../mail/store.ts";
|
|
16
16
|
import type { StoredEvent } from "../types.ts";
|
|
17
|
-
import { mailCommand } from "./mail.ts";
|
|
17
|
+
import { AUTO_NUDGE_TYPES, isDispatchNudge, mailCommand, shouldAutoNudge } from "./mail.ts";
|
|
18
18
|
|
|
19
19
|
describe("mailCommand", () => {
|
|
20
20
|
let tempDir: string;
|
|
@@ -1269,3 +1269,65 @@ describe("mailCommand", () => {
|
|
|
1269
1269
|
});
|
|
1270
1270
|
});
|
|
1271
1271
|
});
|
|
1272
|
+
|
|
1273
|
+
describe("shouldAutoNudge", () => {
|
|
1274
|
+
test("returns true for urgent priority regardless of type", () => {
|
|
1275
|
+
expect(shouldAutoNudge("status", "urgent")).toBe(true);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("returns true for high priority regardless of type", () => {
|
|
1279
|
+
expect(shouldAutoNudge("status", "high")).toBe(true);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("returns true for worker_done type at normal priority", () => {
|
|
1283
|
+
expect(shouldAutoNudge("worker_done", "normal")).toBe(true);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("returns true for merge_ready type at normal priority", () => {
|
|
1287
|
+
expect(shouldAutoNudge("merge_ready", "normal")).toBe(true);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
test("returns true for error type at normal priority", () => {
|
|
1291
|
+
expect(shouldAutoNudge("error", "normal")).toBe(true);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
test("returns false for status type at normal priority", () => {
|
|
1295
|
+
expect(shouldAutoNudge("status", "normal")).toBe(false);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("returns false for question type at low priority", () => {
|
|
1299
|
+
expect(shouldAutoNudge("question", "low")).toBe(false);
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
describe("isDispatchNudge", () => {
|
|
1304
|
+
test("returns true for dispatch type", () => {
|
|
1305
|
+
expect(isDispatchNudge("dispatch")).toBe(true);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test("returns false for worker_done type", () => {
|
|
1309
|
+
expect(isDispatchNudge("worker_done")).toBe(false);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("returns false for status type", () => {
|
|
1313
|
+
expect(isDispatchNudge("status")).toBe(false);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test("returns false for error type", () => {
|
|
1317
|
+
expect(isDispatchNudge("error")).toBe(false);
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
describe("AUTO_NUDGE_TYPES", () => {
|
|
1322
|
+
test("contains worker_done, merge_ready, and error", () => {
|
|
1323
|
+
expect(AUTO_NUDGE_TYPES.has("worker_done")).toBe(true);
|
|
1324
|
+
expect(AUTO_NUDGE_TYPES.has("merge_ready")).toBe(true);
|
|
1325
|
+
expect(AUTO_NUDGE_TYPES.has("error")).toBe(true);
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
test("does not contain regular semantic types", () => {
|
|
1329
|
+
expect(AUTO_NUDGE_TYPES.has("status")).toBe(false);
|
|
1330
|
+
expect(AUTO_NUDGE_TYPES.has("question")).toBe(false);
|
|
1331
|
+
expect(AUTO_NUDGE_TYPES.has("result")).toBe(false);
|
|
1332
|
+
});
|
|
1333
|
+
});
|
package/src/commands/mail.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { MAIL_MESSAGE_TYPES } from "../types.ts";
|
|
|
24
24
|
* Protocol message types that require immediate recipient attention.
|
|
25
25
|
* These trigger auto-nudge regardless of priority level.
|
|
26
26
|
*/
|
|
27
|
-
const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
|
|
27
|
+
export const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
|
|
28
28
|
"worker_done",
|
|
29
29
|
"merge_ready",
|
|
30
30
|
"error",
|
|
@@ -32,6 +32,23 @@ const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
|
|
|
32
32
|
"merge_failed",
|
|
33
33
|
]);
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Check if a message type/priority combination should trigger a pending nudge.
|
|
37
|
+
* Exported for testability.
|
|
38
|
+
*/
|
|
39
|
+
export function shouldAutoNudge(type: MailMessageType, priority: MailMessage["priority"]): boolean {
|
|
40
|
+
return priority === "urgent" || priority === "high" || AUTO_NUDGE_TYPES.has(type);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a message type should trigger an immediate tmux dispatch nudge.
|
|
45
|
+
* Dispatch nudges target newly spawned agents at the welcome screen.
|
|
46
|
+
* Exported for testability.
|
|
47
|
+
*/
|
|
48
|
+
export function isDispatchNudge(type: MailMessageType): boolean {
|
|
49
|
+
return type === "dispatch";
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/** Format a single message for human-readable output. */
|
|
36
53
|
function formatMessage(msg: MailMessage): string {
|
|
37
54
|
const readMarker = msg.read ? " " : "*";
|
package/src/commands/merge.ts
CHANGED
|
@@ -47,7 +47,7 @@ function parseAgentName(branchName: string): string {
|
|
|
47
47
|
* Pattern: overstory/{agentName}/{taskId}
|
|
48
48
|
* Falls back to "unknown" if the pattern does not match.
|
|
49
49
|
*/
|
|
50
|
-
function
|
|
50
|
+
function parseTaskId(branchName: string): string {
|
|
51
51
|
const parts = branchName.split("/");
|
|
52
52
|
if (parts[0] === "overstory" && parts[2] !== undefined) {
|
|
53
53
|
return parts[2];
|
|
@@ -213,7 +213,7 @@ async function handleBranch(
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
const agentName = parseAgentName(branchName);
|
|
216
|
-
const taskId =
|
|
216
|
+
const taskId = parseTaskId(branchName);
|
|
217
217
|
const filesModified = await detectModifiedFiles(repoRoot, canonicalBranch, branchName);
|
|
218
218
|
|
|
219
219
|
entry = queue.enqueue({
|
package/src/commands/monitor.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Unlike regular agents spawned by sling, the monitor:
|
|
10
10
|
* - Has no worktree (operates on the main working tree)
|
|
11
|
-
* - Has no
|
|
11
|
+
* - Has no task assignment (it monitors, not implements)
|
|
12
12
|
* - Has no overlay CLAUDE.md (context comes via ov status + mail)
|
|
13
13
|
* - Persists across patrol cycles
|
|
14
14
|
*/
|
|
@@ -158,7 +158,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
|
|
|
158
158
|
capability: "monitor",
|
|
159
159
|
worktreePath: projectRoot, // Monitor uses project root, not a worktree
|
|
160
160
|
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
161
|
-
taskId: "", // No specific
|
|
161
|
+
taskId: "", // No specific task assignment
|
|
162
162
|
tmuxSession,
|
|
163
163
|
state: "booting",
|
|
164
164
|
pid,
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
buildAutoDispatch,
|
|
9
9
|
buildBeacon,
|
|
10
10
|
calculateStaggerDelay,
|
|
11
|
-
|
|
11
|
+
checkDuplicateLead,
|
|
12
|
+
checkParentAgentLimit,
|
|
12
13
|
checkRunSessionLimit,
|
|
14
|
+
checkTaskLock,
|
|
13
15
|
inferDomainsFromFiles,
|
|
14
16
|
isRunningAsRoot,
|
|
15
17
|
parentHasScouts,
|
|
@@ -554,66 +556,66 @@ describe("isRunningAsRoot", () => {
|
|
|
554
556
|
});
|
|
555
557
|
|
|
556
558
|
/**
|
|
557
|
-
* Tests for
|
|
559
|
+
* Tests for checkTaskLock.
|
|
558
560
|
*
|
|
559
|
-
*
|
|
561
|
+
* checkTaskLock prevents concurrent agents from working the same task ID.
|
|
560
562
|
* It checks the active session list and returns the agent name that holds
|
|
561
|
-
* the lock (i.e., is already working on the
|
|
563
|
+
* the lock (i.e., is already working on the task), or null if the task is free.
|
|
562
564
|
*/
|
|
563
565
|
|
|
564
|
-
function
|
|
566
|
+
function makeTaskSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
|
|
565
567
|
return { agentName, taskId };
|
|
566
568
|
}
|
|
567
569
|
|
|
568
|
-
describe("
|
|
570
|
+
describe("checkTaskLock", () => {
|
|
569
571
|
test("returns null when no sessions exist", () => {
|
|
570
|
-
expect(
|
|
572
|
+
expect(checkTaskLock([], "overstory-abc")).toBeNull();
|
|
571
573
|
});
|
|
572
574
|
|
|
573
575
|
test("returns null when no session matches the task ID", () => {
|
|
574
576
|
const sessions = [
|
|
575
|
-
|
|
576
|
-
|
|
577
|
+
makeTaskSession("builder-1", "overstory-xyz"),
|
|
578
|
+
makeTaskSession("builder-2", "overstory-def"),
|
|
577
579
|
];
|
|
578
580
|
|
|
579
|
-
expect(
|
|
581
|
+
expect(checkTaskLock(sessions, "overstory-abc")).toBeNull();
|
|
580
582
|
});
|
|
581
583
|
|
|
582
584
|
test("returns the agent name when a session matches", () => {
|
|
583
585
|
const sessions = [
|
|
584
|
-
|
|
585
|
-
|
|
586
|
+
makeTaskSession("builder-1", "overstory-abc"),
|
|
587
|
+
makeTaskSession("builder-2", "overstory-xyz"),
|
|
586
588
|
];
|
|
587
589
|
|
|
588
|
-
expect(
|
|
590
|
+
expect(checkTaskLock(sessions, "overstory-abc")).toBe("builder-1");
|
|
589
591
|
});
|
|
590
592
|
|
|
591
593
|
test("returns the first matching agent when multiple sessions match", () => {
|
|
592
594
|
// Multiple sessions can have the same taskId (e.g., retried agent)
|
|
593
|
-
//
|
|
595
|
+
// checkTaskLock returns the first match
|
|
594
596
|
const sessions = [
|
|
595
|
-
|
|
596
|
-
|
|
597
|
+
makeTaskSession("builder-1", "overstory-abc"),
|
|
598
|
+
makeTaskSession("builder-2", "overstory-abc"),
|
|
597
599
|
];
|
|
598
600
|
|
|
599
|
-
expect(
|
|
601
|
+
expect(checkTaskLock(sessions, "overstory-abc")).toBe("builder-1");
|
|
600
602
|
});
|
|
601
603
|
});
|
|
602
604
|
|
|
603
|
-
describe("
|
|
605
|
+
describe("checkTaskLock parent bypass", () => {
|
|
604
606
|
test("parent matching lock holder is allowed (returns lock holder name for caller to compare)", () => {
|
|
605
|
-
//
|
|
606
|
-
// The parent bypass logic is in slingCommand, not
|
|
607
|
+
// checkTaskLock is a pure function — it returns the lock holder name or null.
|
|
608
|
+
// The parent bypass logic is in slingCommand, not checkTaskLock.
|
|
607
609
|
// These tests verify the building blocks work correctly.
|
|
608
|
-
const sessions = [
|
|
609
|
-
//
|
|
610
|
+
const sessions = [makeTaskSession("lead-alpha", "overstory-abc")];
|
|
611
|
+
// checkTaskLock still returns the holder — the caller (slingCommand) decides
|
|
610
612
|
// whether to allow based on parentAgent match.
|
|
611
|
-
expect(
|
|
613
|
+
expect(checkTaskLock(sessions, "overstory-abc")).toBe("lead-alpha");
|
|
612
614
|
});
|
|
613
615
|
|
|
614
616
|
test("non-parent lock holder blocks spawn", () => {
|
|
615
|
-
const sessions = [
|
|
616
|
-
const lockHolder =
|
|
617
|
+
const sessions = [makeTaskSession("other-agent", "overstory-abc")];
|
|
618
|
+
const lockHolder = checkTaskLock(sessions, "overstory-abc");
|
|
617
619
|
const parentAgent = "lead-alpha";
|
|
618
620
|
// lockHolder is 'other-agent', parentAgent is 'lead-alpha' — not equal, should block
|
|
619
621
|
expect(lockHolder).not.toBeNull();
|
|
@@ -621,8 +623,8 @@ describe("checkBeadLock parent bypass", () => {
|
|
|
621
623
|
});
|
|
622
624
|
|
|
623
625
|
test("null parent with lock holder blocks spawn", () => {
|
|
624
|
-
const sessions = [
|
|
625
|
-
const lockHolder =
|
|
626
|
+
const sessions = [makeTaskSession("lead-alpha", "overstory-abc")];
|
|
627
|
+
const lockHolder = checkTaskLock(sessions, "overstory-abc");
|
|
626
628
|
const parentAgent = null;
|
|
627
629
|
// lockHolder is non-null and parentAgent is null — should block
|
|
628
630
|
expect(lockHolder).not.toBeNull();
|
|
@@ -630,6 +632,71 @@ describe("checkBeadLock parent bypass", () => {
|
|
|
630
632
|
});
|
|
631
633
|
});
|
|
632
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Tests for checkDuplicateLead.
|
|
637
|
+
*
|
|
638
|
+
* checkDuplicateLead prevents spawning a second lead agent for the same task ID.
|
|
639
|
+
* It filters the active session list to only "lead" capability sessions, so
|
|
640
|
+
* builder/scout children working the same bead via parent delegation do not
|
|
641
|
+
* trigger this check.
|
|
642
|
+
*
|
|
643
|
+
* The activeSessions input is pre-filtered by store.getActive() to exclude
|
|
644
|
+
* completed and zombie sessions.
|
|
645
|
+
*/
|
|
646
|
+
|
|
647
|
+
function makeLeadSession(
|
|
648
|
+
agentName: string,
|
|
649
|
+
taskId: string,
|
|
650
|
+
capability: string,
|
|
651
|
+
): { agentName: string; taskId: string; capability: string } {
|
|
652
|
+
return { agentName, taskId, capability };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
describe("checkDuplicateLead", () => {
|
|
656
|
+
test("returns lead agent name when an active lead exists for the task", () => {
|
|
657
|
+
const sessions = [
|
|
658
|
+
makeLeadSession("lead-alpha", "overstory-abc", "lead"),
|
|
659
|
+
makeLeadSession("builder-1", "overstory-xyz", "builder"),
|
|
660
|
+
];
|
|
661
|
+
expect(checkDuplicateLead(sessions, "overstory-abc")).toBe("lead-alpha");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("returns null when no lead exists for the task", () => {
|
|
665
|
+
const sessions = [
|
|
666
|
+
makeLeadSession("lead-alpha", "overstory-xyz", "lead"),
|
|
667
|
+
makeLeadSession("builder-1", "overstory-abc", "builder"),
|
|
668
|
+
];
|
|
669
|
+
expect(checkDuplicateLead(sessions, "overstory-abc")).toBeNull();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("returns null when no sessions exist (completed/zombie filtered out)", () => {
|
|
673
|
+
// activeSessions from store.getActive() already excludes completed/zombie
|
|
674
|
+
expect(checkDuplicateLead([], "overstory-abc")).toBeNull();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("ignores non-lead agents working the same bead", () => {
|
|
678
|
+
const sessions = [
|
|
679
|
+
makeLeadSession("builder-1", "overstory-abc", "builder"),
|
|
680
|
+
makeLeadSession("scout-1", "overstory-abc", "scout"),
|
|
681
|
+
makeLeadSession("reviewer-1", "overstory-abc", "reviewer"),
|
|
682
|
+
];
|
|
683
|
+
expect(checkDuplicateLead(sessions, "overstory-abc")).toBeNull();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("returns first matching lead when multiple leads exist for the same bead", () => {
|
|
687
|
+
const sessions = [
|
|
688
|
+
makeLeadSession("lead-alpha", "overstory-abc", "lead"),
|
|
689
|
+
makeLeadSession("lead-beta", "overstory-abc", "lead"),
|
|
690
|
+
];
|
|
691
|
+
expect(checkDuplicateLead(sessions, "overstory-abc")).toBe("lead-alpha");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("differentiates between task IDs", () => {
|
|
695
|
+
const sessions = [makeLeadSession("lead-alpha", "overstory-abc", "lead")];
|
|
696
|
+
expect(checkDuplicateLead(sessions, "overstory-xyz")).toBeNull();
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
633
700
|
/**
|
|
634
701
|
* Tests for checkRunSessionLimit.
|
|
635
702
|
*
|
|
@@ -660,6 +727,67 @@ describe("checkRunSessionLimit", () => {
|
|
|
660
727
|
});
|
|
661
728
|
});
|
|
662
729
|
|
|
730
|
+
describe("checkParentAgentLimit", () => {
|
|
731
|
+
test("returns false when limit is 0 (unlimited)", () => {
|
|
732
|
+
const sessions = [{ parentAgent: "lead-alpha" }, { parentAgent: "lead-alpha" }];
|
|
733
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 0)).toBe(false);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("returns false when count is below limit", () => {
|
|
737
|
+
const sessions = [{ parentAgent: "lead-alpha" }];
|
|
738
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 5)).toBe(false);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("returns true when count equals limit", () => {
|
|
742
|
+
const sessions = [
|
|
743
|
+
{ parentAgent: "lead-alpha" },
|
|
744
|
+
{ parentAgent: "lead-alpha" },
|
|
745
|
+
{ parentAgent: "lead-alpha" },
|
|
746
|
+
];
|
|
747
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("returns true when count exceeds limit", () => {
|
|
751
|
+
const sessions = [
|
|
752
|
+
{ parentAgent: "lead-alpha" },
|
|
753
|
+
{ parentAgent: "lead-alpha" },
|
|
754
|
+
{ parentAgent: "lead-alpha" },
|
|
755
|
+
{ parentAgent: "lead-alpha" },
|
|
756
|
+
];
|
|
757
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("returns false when limit is negative (treated as unlimited)", () => {
|
|
761
|
+
const sessions = [{ parentAgent: "lead-alpha" }];
|
|
762
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", -1)).toBe(false);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("only counts children of the specified parent", () => {
|
|
766
|
+
const sessions = [
|
|
767
|
+
{ parentAgent: "lead-alpha" },
|
|
768
|
+
{ parentAgent: "lead-beta" },
|
|
769
|
+
{ parentAgent: "lead-alpha" },
|
|
770
|
+
{ parentAgent: "lead-gamma" },
|
|
771
|
+
];
|
|
772
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(false);
|
|
773
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(true);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("returns false when no sessions match the parent", () => {
|
|
777
|
+
const sessions = [{ parentAgent: "lead-beta" }, { parentAgent: "lead-gamma" }];
|
|
778
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 1)).toBe(false);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("ignores sessions with null parent", () => {
|
|
782
|
+
const sessions = [{ parentAgent: null }, { parentAgent: "lead-alpha" }, { parentAgent: null }];
|
|
783
|
+
expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(false);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("returns false when sessions array is empty", () => {
|
|
787
|
+
expect(checkParentAgentLimit([], "lead-alpha", 5)).toBe(false);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
663
791
|
/**
|
|
664
792
|
* Tests for sling provider env injection building blocks.
|
|
665
793
|
*
|
|
@@ -691,6 +819,7 @@ function makeConfig(
|
|
|
691
819
|
staggerDelayMs: 0,
|
|
692
820
|
maxDepth: 2,
|
|
693
821
|
maxSessionsPerRun: 0,
|
|
822
|
+
maxAgentsPerLead: 5,
|
|
694
823
|
},
|
|
695
824
|
worktrees: { baseDir: ".overstory/worktrees" },
|
|
696
825
|
taskTracker: { backend: "auto", enabled: false },
|
|
@@ -933,3 +1062,21 @@ describe("buildAutoDispatch", () => {
|
|
|
933
1062
|
expect(dispatch.to).toBe("my-builder");
|
|
934
1063
|
});
|
|
935
1064
|
});
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Beacon verification loop (sling.ts step 13d)
|
|
1068
|
+
*
|
|
1069
|
+
* NOT UNIT-TESTABLE: The beacon verification loop at sling.ts lines 687-698
|
|
1070
|
+
* uses capturePaneContent() to poll tmux and resend the beacon if the agent
|
|
1071
|
+
* is still at the welcome screen ("Try "). This involves real tmux operations
|
|
1072
|
+
* that cannot be reliably mocked without mock.module() (which leaks across
|
|
1073
|
+
* test files — see mx-56558b).
|
|
1074
|
+
*
|
|
1075
|
+
* Manual verification:
|
|
1076
|
+
* 1. `ov sling <task-id> --name test --capability builder`
|
|
1077
|
+
* 2. Watch tmux pane: `tmux capture-pane -t overstory-<project>-test -p`
|
|
1078
|
+
* 3. Verify the beacon text appears and the agent starts processing
|
|
1079
|
+
*
|
|
1080
|
+
* Integration coverage: The beacon loop has been validated through production
|
|
1081
|
+
* agent spawns. Failure mode is agents stuck at welcome screen (overstory-3271).
|
|
1082
|
+
*/
|