@selesai/code 0.8.4 → 0.8.5
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/CHANGELOG.md +10 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +41 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/docs/configuration.md +46 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +21 -0
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +11 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +614 -187
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/types.ts +92 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +225 -12
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +2 -1
|
@@ -68,6 +68,7 @@ const originalEnv = {
|
|
|
68
68
|
[PI_INTERCOM_SESSION_ID_ENV]: process.env[PI_INTERCOM_SESSION_ID_ENV],
|
|
69
69
|
MCP_HASH_ROOT: process.env.MCP_HASH_ROOT,
|
|
70
70
|
MCP_HASH_TOKEN: process.env.MCP_HASH_TOKEN,
|
|
71
|
+
SELESAI_SUBAGENT_TASK_DELIVERY: process.env.SELESAI_SUBAGENT_TASK_DELIVERY,
|
|
71
72
|
};
|
|
72
73
|
const originalCwd = process.cwd();
|
|
73
74
|
const tempRoots: string[] = [];
|
|
@@ -481,6 +482,88 @@ describe("buildPiArgs model wiring", () => {
|
|
|
481
482
|
});
|
|
482
483
|
});
|
|
483
484
|
|
|
485
|
+
describe("buildPiArgs task delivery", () => {
|
|
486
|
+
const longTask = "x".repeat(8001);
|
|
487
|
+
|
|
488
|
+
function taskFileFromArgs(args: string[]): string | undefined {
|
|
489
|
+
const ref = args.find((arg) => arg.startsWith("@") && arg.endsWith("task.md"));
|
|
490
|
+
return ref ? ref.slice(1) : undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
it("delivers short tasks inline by default", () => {
|
|
494
|
+
const { args } = buildPiArgs({
|
|
495
|
+
baseArgs: ["-p"],
|
|
496
|
+
task: "hello",
|
|
497
|
+
sessionEnabled: false,
|
|
498
|
+
inheritProjectContext: false,
|
|
499
|
+
inheritSkills: false,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
assert.ok(args.includes("Task: hello"));
|
|
503
|
+
assert.equal(taskFileFromArgs(args), undefined);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("delivers tasks over the argv limit via a temp file by default", () => {
|
|
507
|
+
const { args } = buildPiArgs({
|
|
508
|
+
baseArgs: ["-p"],
|
|
509
|
+
task: longTask,
|
|
510
|
+
sessionEnabled: false,
|
|
511
|
+
inheritProjectContext: false,
|
|
512
|
+
inheritSkills: false,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const taskFile = taskFileFromArgs(args);
|
|
516
|
+
assert.ok(taskFile, "expected an @task.md argv reference");
|
|
517
|
+
assert.equal(fs.readFileSync(taskFile, "utf-8"), `Task: ${longTask}`);
|
|
518
|
+
assert.ok(!args.includes(`Task: ${longTask}`));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("delivers short tasks via file when SELESAI_SUBAGENT_TASK_DELIVERY=file", () => {
|
|
522
|
+
process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "file";
|
|
523
|
+
const { args } = buildPiArgs({
|
|
524
|
+
baseArgs: ["-p"],
|
|
525
|
+
task: "hello",
|
|
526
|
+
sessionEnabled: false,
|
|
527
|
+
inheritProjectContext: false,
|
|
528
|
+
inheritSkills: false,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const taskFile = taskFileFromArgs(args);
|
|
532
|
+
assert.ok(taskFile, "expected an @task.md argv reference");
|
|
533
|
+
assert.equal(fs.readFileSync(taskFile, "utf-8"), "Task: hello");
|
|
534
|
+
assert.ok(!args.includes("Task: hello"));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
it("falls back to auto when SELESAI_SUBAGENT_TASK_DELIVERY is invalid", () => {
|
|
539
|
+
process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "carrier-pigeon";
|
|
540
|
+
const { args } = buildPiArgs({
|
|
541
|
+
baseArgs: ["-p"],
|
|
542
|
+
task: longTask,
|
|
543
|
+
sessionEnabled: false,
|
|
544
|
+
inheritProjectContext: false,
|
|
545
|
+
inheritSkills: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
assert.ok(taskFileFromArgs(args), "expected file delivery for over-limit task");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("lets the per-launch taskDelivery override beat the env setting", () => {
|
|
552
|
+
delete process.env.SELESAI_SUBAGENT_TASK_DELIVERY;
|
|
553
|
+
const { args } = buildPiArgs({
|
|
554
|
+
baseArgs: ["-p"],
|
|
555
|
+
task: "hello",
|
|
556
|
+
taskDelivery: "file",
|
|
557
|
+
sessionEnabled: false,
|
|
558
|
+
inheritProjectContext: false,
|
|
559
|
+
inheritSkills: false,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
assert.ok(taskFileFromArgs(args), "expected an @task.md argv reference");
|
|
563
|
+
assert.ok(!args.includes("Task: hello"));
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
484
567
|
describe("buildPiArgs system prompt mode wiring", () => {
|
|
485
568
|
it("uses --append-system-prompt by default", () => {
|
|
486
569
|
const { args } = buildPiArgs({
|
|
@@ -182,14 +182,14 @@ Package skill content.
|
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it("records private redacted run history and cleans session artifacts under the configured agent dir", () => {
|
|
185
|
-
const task = "Inspect customer ACME token=SECRET";
|
|
185
|
+
const task = "PROMPT_AUDIT_SENTINEL_1021 Inspect customer ACME token=SECRET";
|
|
186
186
|
recordRun("env-agent", task, 0, 42);
|
|
187
187
|
const historyPath = path.join(agentDir, "run-history.jsonl");
|
|
188
188
|
assert.equal(fs.existsSync(historyPath), true);
|
|
189
189
|
assertPrivateHistoryModes(historyPath);
|
|
190
190
|
|
|
191
191
|
const rawHistory = fs.readFileSync(historyPath, "utf-8");
|
|
192
|
-
assert.doesNotMatch(rawHistory, /Inspect customer|ACME|SECRET/);
|
|
192
|
+
assert.doesNotMatch(rawHistory, /PROMPT_AUDIT_SENTINEL_1021|Inspect customer|ACME|SECRET/);
|
|
193
193
|
assert.match(rawHistory, /"task":"\[redacted\]"/);
|
|
194
194
|
assert.match(rawHistory, /"taskHash":"[a-f0-9]{64}"/);
|
|
195
195
|
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { ACTIVE_RUN_INDEX_DIR, updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
|
|
6
7
|
import {
|
|
7
8
|
finalizeProcessTerminal,
|
|
8
9
|
processTerminalPath,
|
|
@@ -27,6 +28,7 @@ test("process-terminal proof requires the matching runner instance and writer cl
|
|
|
27
28
|
closeObservedAt: 10,
|
|
28
29
|
exitCode: 0,
|
|
29
30
|
signal: null,
|
|
31
|
+
processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 123, verifiedAt: 11 },
|
|
30
32
|
}],
|
|
31
33
|
},
|
|
32
34
|
});
|
|
@@ -82,6 +84,57 @@ test("process-terminal rejects missing writer close evidence", () => {
|
|
|
82
84
|
}
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
test("process-terminal fails closed when process-tree verification is unknown", () => {
|
|
88
|
+
const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
|
|
89
|
+
try {
|
|
90
|
+
writeProcessTerminalCandidate(asyncDir, {
|
|
91
|
+
version: 1,
|
|
92
|
+
runId: "run-tree-unknown",
|
|
93
|
+
runnerProcessInstanceId: "runner-tree-unknown",
|
|
94
|
+
expectedWriters: { "0": 1 },
|
|
95
|
+
writers: {
|
|
96
|
+
"0": [{
|
|
97
|
+
processInstanceId: "writer-tree-unknown",
|
|
98
|
+
kind: "pi-writer",
|
|
99
|
+
attempt: 0,
|
|
100
|
+
closeObservedAt: 10,
|
|
101
|
+
exitCode: 1,
|
|
102
|
+
signal: "SIGKILL",
|
|
103
|
+
processTree: { state: "unknown", reason: "verification-failed", diagnostic: "still live" },
|
|
104
|
+
}],
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "run-tree-unknown", state: "failed", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "failed" }] }));
|
|
108
|
+
fs.writeFileSync(path.join(asyncDir, "events.jsonl"), "");
|
|
109
|
+
updateActiveRunIndex(asyncDir, "running");
|
|
110
|
+
const markerPath = path.join(path.dirname(asyncDir), ACTIVE_RUN_INDEX_DIR, path.basename(asyncDir));
|
|
111
|
+
const proof = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
|
|
112
|
+
processInstanceId: "runner-tree-unknown",
|
|
113
|
+
closeObservedAt: 20,
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
signal: null,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(proof.state, "unknown");
|
|
118
|
+
assert.equal(proof.reason, "process-tree-unverified");
|
|
119
|
+
assert.equal(fs.existsSync(markerPath), true);
|
|
120
|
+
fs.rmSync(processTerminalPath(asyncDir), { force: true });
|
|
121
|
+
const candidatePath = path.join(asyncDir, "process-terminal-candidate.json");
|
|
122
|
+
const candidate = JSON.parse(fs.readFileSync(candidatePath, "utf-8"));
|
|
123
|
+
candidate.writers["0"][0].processTree = { state: "observed", mechanism: "posix-process-group", processGroupId: 126, verifiedAt: 21 };
|
|
124
|
+
fs.writeFileSync(candidatePath, JSON.stringify(candidate));
|
|
125
|
+
const observed = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
|
|
126
|
+
processInstanceId: "runner-tree-unknown",
|
|
127
|
+
closeObservedAt: 30,
|
|
128
|
+
exitCode: 1,
|
|
129
|
+
signal: null,
|
|
130
|
+
});
|
|
131
|
+
assert.equal(observed.state, "observed");
|
|
132
|
+
assert.equal(fs.existsSync(markerPath), false);
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
85
138
|
test("malformed process-terminal sidecars project unknown instead of throwing", () => {
|
|
86
139
|
const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
|
|
87
140
|
try {
|
|
@@ -112,7 +165,7 @@ test("process-terminal preserves stopped non-resumability and requires lease rel
|
|
|
112
165
|
runnerProcessInstanceId: "stopped-runner",
|
|
113
166
|
expectedWriters: { "0": 1 },
|
|
114
167
|
writers: {
|
|
115
|
-
"0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
|
|
168
|
+
"0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 124, verifiedAt: 11 } }],
|
|
116
169
|
},
|
|
117
170
|
revivalLeaseToken: "lease-token",
|
|
118
171
|
revivalLeaseReleaseAcknowledged: false,
|
|
@@ -161,7 +214,7 @@ test("process-terminal rejects cross-run observed sidecars and inconsistent expe
|
|
|
161
214
|
runnerProcessInstanceId: "runner-1",
|
|
162
215
|
expectedWriters: { "0": 0 },
|
|
163
216
|
writers: {
|
|
164
|
-
"0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
|
|
217
|
+
"0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 125, verifiedAt: 11 } }],
|
|
165
218
|
},
|
|
166
219
|
});
|
|
167
220
|
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "actual-run", state: "complete", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "complete" }] }));
|
|
@@ -19,7 +19,18 @@ describe("public subagent execution normalization", () => {
|
|
|
19
19
|
);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("rejects
|
|
22
|
+
it("rejects private run fan-out fields at the public boundary", () => {
|
|
23
|
+
for (const params of [
|
|
24
|
+
{ workflowScript: "return 1", runFanoutBudget: { version: 1 } },
|
|
25
|
+
{ workflowScript: "return 1", runFanoutAdmitted: true },
|
|
26
|
+
] as const) {
|
|
27
|
+
const result = normalizePublicSubagentExecution(params);
|
|
28
|
+
assert.equal(result.ok, false);
|
|
29
|
+
if (!result.ok) assert.match(result.error, /does not accept internal run fan-out fields/);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects removed public execution shapes", () => {
|
|
23
34
|
for (const params of [
|
|
24
35
|
{ action: " " },
|
|
25
36
|
{ action: "single" },
|
|
@@ -146,7 +146,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
146
146
|
assert.deepEqual(executedParams, { action: "status", id: "abc123" });
|
|
147
147
|
assert.equal((reply as { data: { text?: string } }).data.text, "Run: abc123");
|
|
148
148
|
assert.deepEqual((reply as { data: { fleet?: unknown } }).data.fleet, {
|
|
149
|
-
version: 1, entries: [], totalActive: 0, omitted: 0,
|
|
149
|
+
version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0,
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
bridge.dispose();
|
|
@@ -174,8 +174,9 @@ describe("subagent extension RPC bridge", () => {
|
|
|
174
174
|
assert.equal((fleet as { omitted?: number }).omitted, 0);
|
|
175
175
|
assert.deepEqual(fleet.entries[0], {
|
|
176
176
|
key: "fleet-1", agent: "reviewer", role: "opaque label", model: "anthropic/claude-opus-4-8:high", effort: "high",
|
|
177
|
-
startedAt: 120, tokens: { input: 12, output: 34, total: 46 },
|
|
177
|
+
startedAt: 120, tokens: { input: 12, output: 34, total: 46 },
|
|
178
178
|
});
|
|
179
|
+
assert.equal(JSON.stringify(fleet).includes("Review the diff"), false);
|
|
179
180
|
assert.equal(JSON.stringify(fleet).includes("async-private-id"), false);
|
|
180
181
|
bridge.dispose();
|
|
181
182
|
});
|
|
@@ -200,15 +201,13 @@ describe("subagent extension RPC bridge", () => {
|
|
|
200
201
|
const malformedSurrogate = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/u;
|
|
201
202
|
|
|
202
203
|
assert.doesNotMatch(entry.agent, malformedSurrogate);
|
|
203
|
-
assert.
|
|
204
|
-
assert.doesNotMatch(entry.goal, /[\r\n]/);
|
|
204
|
+
assert.equal(entry.goal, undefined);
|
|
205
205
|
assert.ok(entry.agent.length <= 96);
|
|
206
|
-
assert.ok(entry.goal.length <= 512);
|
|
207
206
|
assert.match(entry.agent, /^worker broken/);
|
|
208
207
|
bridge.dispose();
|
|
209
208
|
});
|
|
210
209
|
|
|
211
|
-
it("projects resolved foreground model, effort, split usage
|
|
210
|
+
it("projects resolved foreground model, effort, and split usage without prompt goals", async () => {
|
|
212
211
|
const events = new FakeEvents();
|
|
213
212
|
const state = {
|
|
214
213
|
currentSessionId: "session-123",
|
|
@@ -231,6 +230,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
231
230
|
}]]),
|
|
232
231
|
}]]),
|
|
233
232
|
asyncJobs: new Map(),
|
|
233
|
+
activeAsyncCapacity: { used: 2, limit: 4 },
|
|
234
234
|
} as any;
|
|
235
235
|
state.foregroundControls.set("private-old", {
|
|
236
236
|
runId: "private-old",
|
|
@@ -250,6 +250,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
250
250
|
assert.deepEqual((reply as any).data.fleet, {
|
|
251
251
|
version: 1,
|
|
252
252
|
totalActive: 1,
|
|
253
|
+
topLevelAsyncCapacity: { used: 2, limit: 4 },
|
|
253
254
|
omitted: 0,
|
|
254
255
|
entries: [{
|
|
255
256
|
key: "fleet-1",
|
|
@@ -258,7 +259,6 @@ describe("subagent extension RPC bridge", () => {
|
|
|
258
259
|
effort: "high",
|
|
259
260
|
startedAt: 100,
|
|
260
261
|
tokens: { input: 321, output: 45, total: 366 },
|
|
261
|
-
goal: "Implement the fix",
|
|
262
262
|
}],
|
|
263
263
|
});
|
|
264
264
|
assert.equal(JSON.stringify((reply as any).data.fleet).includes("private-run"), false);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { afterEach, describe, it } from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
claimRunFanoutBatch,
|
|
9
|
+
claimRunFanoutBatchWithCommit,
|
|
10
|
+
createRunFanoutBudget,
|
|
11
|
+
decodeRunFanoutBudgetDescriptor,
|
|
12
|
+
encodeRunFanoutBudgetDescriptor,
|
|
13
|
+
getRunFanoutBudgetSnapshot,
|
|
14
|
+
RunFanoutLimitError,
|
|
15
|
+
validateRunFanoutBudgetDescriptor,
|
|
16
|
+
} from "../../src/runs/shared/run-fanout-budget.ts";
|
|
17
|
+
import { resolveMaxSubagentSpawnsPerRun } from "../../src/shared/types.ts";
|
|
18
|
+
|
|
19
|
+
const directories: string[] = [];
|
|
20
|
+
const externalDirectories: string[] = [];
|
|
21
|
+
const previousEnv = process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
25
|
+
for (const directory of externalDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
26
|
+
if (previousEnv === undefined) delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
27
|
+
else process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = previousEnv;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function budget(limit: number) {
|
|
31
|
+
const descriptor = createRunFanoutBudget(`test-${Date.now()}-${Math.random()}`, limit);
|
|
32
|
+
directories.push(descriptor.directory);
|
|
33
|
+
return descriptor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("run fan-out budget", () => {
|
|
37
|
+
it("resolves environment over config and falls back to 64", () => {
|
|
38
|
+
delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
39
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
|
|
40
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(12), 12);
|
|
41
|
+
for (const invalid of [0, -1, 1.5, "bad"]) assert.equal(resolveMaxSubagentSpawnsPerRun(invalid as number), 64);
|
|
42
|
+
process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = "7";
|
|
43
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(12), 7);
|
|
44
|
+
for (const invalid of ["0", "-1", "1.5", "bad"]) {
|
|
45
|
+
process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = invalid;
|
|
46
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("persists claims and rejects the responsible next path at the exact cap", () => {
|
|
51
|
+
const descriptor = budget(2);
|
|
52
|
+
assert.deepEqual(claimRunFanoutBatch(descriptor, ["tasks[0]", "tasks[1]"]), { used: 2, limit: 2, remaining: 0 });
|
|
53
|
+
const restored = decodeRunFanoutBudgetDescriptor(encodeRunFanoutBudgetDescriptor(descriptor));
|
|
54
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(restored!), { used: 2, limit: 2, remaining: 0 });
|
|
55
|
+
assert.throws(() => claimRunFanoutBatch(descriptor, ["tasks[2]"]), (error: unknown) => {
|
|
56
|
+
assert.ok(error instanceof RunFanoutLimitError);
|
|
57
|
+
assert.equal(error.rejection.path, "tasks[2]");
|
|
58
|
+
assert.equal(error.rejection.used, 2);
|
|
59
|
+
assert.equal(error.rejection.remaining, 0);
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rolls back only the failed admission batch", () => {
|
|
65
|
+
const descriptor = budget(2);
|
|
66
|
+
claimRunFanoutBatch(descriptor, ["single"]);
|
|
67
|
+
assert.throws(() => claimRunFanoutBatch(descriptor, ["chain[0]", "chain[1]"]), RunFanoutLimitError);
|
|
68
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rolls back a batch when its commit fails", () => {
|
|
72
|
+
const descriptor = budget(2);
|
|
73
|
+
claimRunFanoutBatch(descriptor, ["existing"]);
|
|
74
|
+
assert.throws(
|
|
75
|
+
() => claimRunFanoutBatchWithCommit(descriptor, ["append"], () => { throw new Error("enqueue failed"); }),
|
|
76
|
+
/enqueue failed/,
|
|
77
|
+
);
|
|
78
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports claims-directory I/O errors instead of ordinary exhaustion", () => {
|
|
82
|
+
const descriptor = budget(2);
|
|
83
|
+
fs.rmSync(path.join(descriptor.directory, "claims"), { recursive: true });
|
|
84
|
+
fs.writeFileSync(path.join(descriptor.directory, "claims"), "not a directory", "utf-8");
|
|
85
|
+
assert.throws(() => getRunFanoutBudgetSnapshot(descriptor), /claims directory is unreadable.*ENOTDIR/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("admits exactly one simultaneous two-slot batch at limit two", async () => {
|
|
89
|
+
const moduleUrl = new URL("../../src/runs/shared/run-fanout-budget.ts", import.meta.url).href;
|
|
90
|
+
const script = `import { claimRunFanoutBatch } from ${JSON.stringify(moduleUrl)}; const descriptor = JSON.parse(process.argv[1]); try { claimRunFanoutBatch(descriptor, [process.argv[2] + "[0]", process.argv[2] + "[1]"]); process.stdout.write("admitted"); } catch { process.stdout.write("rejected"); }`;
|
|
91
|
+
for (let iteration = 0; iteration < 12; iteration++) {
|
|
92
|
+
const descriptor = budget(2);
|
|
93
|
+
const launch = (claimPath: string) => new Promise<string>((resolve, reject) => {
|
|
94
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script, JSON.stringify(descriptor), claimPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
98
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
99
|
+
child.on("error", reject);
|
|
100
|
+
child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr)));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.deepEqual((await Promise.all([launch("group-a"), launch("group-b")])).sort(), ["admitted", "rejected"], `iteration ${iteration}`);
|
|
104
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 2, limit: 2, remaining: 0 });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fails closed when descriptor identity or managed-root containment is invalid", () => {
|
|
109
|
+
const descriptor = budget(2);
|
|
110
|
+
assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, limit: 3 }), /does not match/);
|
|
111
|
+
|
|
112
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-fanout-outside-"));
|
|
113
|
+
externalDirectories.push(outside);
|
|
114
|
+
fs.mkdirSync(path.join(outside, "claims"));
|
|
115
|
+
fs.writeFileSync(path.join(outside, "manifest.json"), JSON.stringify({ version: 1, rootRunId: descriptor.rootRunId, limit: 2, createdAt: Date.now() }));
|
|
116
|
+
const linked = path.join(path.dirname(descriptor.directory), `linked-${Date.now()}`);
|
|
117
|
+
fs.symlinkSync(outside, linked, process.platform === "win32" ? "junction" : "dir");
|
|
118
|
+
directories.push(linked);
|
|
119
|
+
assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, directory: linked }), /resolves outside/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -6,6 +6,7 @@ import { describe, it } from "node:test";
|
|
|
6
6
|
import { updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
|
|
7
7
|
import { inspectSubagentStatus } from "../../src/runs/background/run-status.ts";
|
|
8
8
|
import { createNestedRoute, writeNestedEvent } from "../../src/runs/shared/nested-events.ts";
|
|
9
|
+
import { claimRunFanoutBatch, createRunFanoutBudget, writeRunFanoutBudgetDescriptor } from "../../src/runs/shared/run-fanout-budget.ts";
|
|
9
10
|
import { TEMP_ROOT_DIR, type SubagentState } from "../../src/shared/types.ts";
|
|
10
11
|
|
|
11
12
|
function errno(code: string): NodeJS.ErrnoException {
|
|
@@ -22,6 +23,7 @@ function textContent(result: ReturnType<typeof inspectSubagentStatus>): string {
|
|
|
22
23
|
describe("async run status inspection", () => {
|
|
23
24
|
it("repairs stale running status and reports diagnosis plus result path", () => {
|
|
24
25
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-status-stale-"));
|
|
26
|
+
let budgetDirectory: string | undefined;
|
|
25
27
|
try {
|
|
26
28
|
const asyncRoot = path.join(root, "runs");
|
|
27
29
|
const resultsDir = path.join(root, "results");
|
|
@@ -40,6 +42,10 @@ describe("async run status inspection", () => {
|
|
|
40
42
|
sessionFile,
|
|
41
43
|
steps: [{ agent: "scout", status: "running", startedAt: 100, sessionFile }],
|
|
42
44
|
}, null, 2), "utf-8");
|
|
45
|
+
const descriptor = createRunFanoutBudget("run-stale", 64);
|
|
46
|
+
budgetDirectory = descriptor.directory;
|
|
47
|
+
writeRunFanoutBudgetDescriptor(asyncDir, descriptor);
|
|
48
|
+
claimRunFanoutBatch(descriptor, ["single"]);
|
|
43
49
|
|
|
44
50
|
const result = inspectSubagentStatus({ id: "run-stale" }, {
|
|
45
51
|
asyncDirRoot: asyncRoot,
|
|
@@ -51,6 +57,8 @@ describe("async run status inspection", () => {
|
|
|
51
57
|
const text = textContent(result);
|
|
52
58
|
assert.equal(result.isError, undefined);
|
|
53
59
|
assert.match(text, /State: failed/);
|
|
60
|
+
assert.match(text, /Run fan-out: 1\/64 used, 63 remaining/);
|
|
61
|
+
assert.deepEqual(result.details.runFanoutBudget, { used: 1, limit: 64, remaining: 63 });
|
|
54
62
|
assert.match(text, /Diagnosis: Async runner process 12345 exited or disappeared/);
|
|
55
63
|
assert.match(text, new RegExp(`Result: ${path.join(resultsDir, "run-stale.json").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
56
64
|
assert.match(text, /Step 1: scout failed, error: Async runner process 12345 exited or disappeared/);
|
|
@@ -60,6 +68,7 @@ describe("async run status inspection", () => {
|
|
|
60
68
|
assert.equal(resultJson.results[0].sessionFile, sessionFile);
|
|
61
69
|
} finally {
|
|
62
70
|
fs.rmSync(root, { recursive: true, force: true });
|
|
71
|
+
if (budgetDirectory) fs.rmSync(budgetDirectory, { recursive: true, force: true });
|
|
63
72
|
}
|
|
64
73
|
});
|
|
65
74
|
|
|
@@ -236,12 +236,13 @@ describe("SubagentParams schema", { skip: !schemasAvailable ? "typebox not avail
|
|
|
236
236
|
assert.ok(maxRuntimeSchema, "maxRuntimeMs schema should exist");
|
|
237
237
|
assert.equal(timeoutSchema.minimum, 1);
|
|
238
238
|
assert.equal(maxRuntimeSchema.minimum, 1);
|
|
239
|
-
assert.match(String(timeoutSchema.description ?? ""), /foreground
|
|
240
|
-
assert.match(String(timeoutSchema.description ?? ""), /
|
|
239
|
+
assert.match(String(timeoutSchema.description ?? ""), /foreground and single async runs/i);
|
|
240
|
+
assert.match(String(timeoutSchema.description ?? ""), /use config timeoutMs, else 30m/i);
|
|
241
241
|
assert.match(String(timeoutSchema.description ?? ""), /async composites have no default parent deadline/i);
|
|
242
242
|
assert.doesNotMatch(String(timeoutSchema.description ?? ""), /foreground-only/i);
|
|
243
243
|
assert.match(String(maxRuntimeSchema.description ?? ""), /timeoutMs/i);
|
|
244
|
-
assert.match(String(maxRuntimeSchema.description ?? ""), /
|
|
244
|
+
assert.match(String(maxRuntimeSchema.description ?? ""), /foreground and single async runs/i);
|
|
245
|
+
assert.match(String(maxRuntimeSchema.description ?? ""), /use config timeoutMs, else 30m/i);
|
|
245
246
|
assert.match(String(maxRuntimeSchema.description ?? ""), /async composites have no default parent deadline/i);
|
|
246
247
|
assert.equal(turnBudgetSchema?.properties?.maxTurns?.minimum, 1);
|
|
247
248
|
assert.equal(turnBudgetSchema?.properties?.graceTurns?.minimum, 0);
|
|
@@ -309,4 +309,32 @@ describe("finalizeSingleOutput", () => {
|
|
|
309
309
|
|
|
310
310
|
assert.equal(result.displayOutput, "truncated output");
|
|
311
311
|
});
|
|
312
|
+
|
|
313
|
+
it("preserves the saved-output reference for acceptance-only failures", () => {
|
|
314
|
+
const result = finalizeSingleOutput({
|
|
315
|
+
fullOutput: "useful output",
|
|
316
|
+
outputPath: "/tmp/review.md",
|
|
317
|
+
outputMode: "file-only",
|
|
318
|
+
exitCode: 1,
|
|
319
|
+
preserveSavedOutput: true,
|
|
320
|
+
savedPath: "/tmp/review.md",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
assert.match(result.displayOutput, /^Output saved to:/);
|
|
324
|
+
assert.doesNotMatch(result.displayOutput, /useful output/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("does not duplicate an existing saved-output reference", () => {
|
|
328
|
+
const outputReference = formatSavedOutputReference("/tmp/review.md", "useful output");
|
|
329
|
+
const result = finalizeSingleOutput({
|
|
330
|
+
fullOutput: `useful output\n\n${outputReference.message}`,
|
|
331
|
+
outputPath: "/tmp/review.md",
|
|
332
|
+
exitCode: 1,
|
|
333
|
+
preserveSavedOutput: true,
|
|
334
|
+
savedPath: "/tmp/review.md",
|
|
335
|
+
outputReference,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
assert.equal(result.displayOutput.match(/Output saved to:/g)?.length, 1);
|
|
339
|
+
});
|
|
312
340
|
});
|
|
@@ -2,11 +2,12 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { describe, it } from "node:test";
|
|
5
|
+
import { afterEach, describe, it } from "node:test";
|
|
6
6
|
import { writeAtomicJson, writePrivateAtomicJson } from "../../src/shared/atomic-json.ts";
|
|
7
7
|
import { closeSteerInbox, interruptRequestPath, steerRequestsDir, writeSteerAck, type SteerRequest } from "../../src/runs/background/control-channel.ts";
|
|
8
8
|
import { steerAsyncRun } from "../../src/runs/foreground/async-steering-action.ts";
|
|
9
9
|
import { createSteeringStatus, recordSteeringRequest, updateSteeringTarget } from "../../src/runs/background/steering.ts";
|
|
10
|
+
import { createRunFanoutBudget } from "../../src/runs/shared/run-fanout-budget.ts";
|
|
10
11
|
import { ASYNC_DIR, type AsyncStatus, type Details, type SteeringRecoveryDescriptor, type SteeringTargetState, type SubagentState } from "../../src/shared/types.ts";
|
|
11
12
|
|
|
12
13
|
function createState(): SubagentState {
|
|
@@ -29,6 +30,11 @@ function createState(): SubagentState {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
let statusWriteMtimeMs = Date.now();
|
|
33
|
+
const budgetDirectories: string[] = [];
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
for (const directory of budgetDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
37
|
+
});
|
|
32
38
|
|
|
33
39
|
function writeStatus(asyncDir: string, status: AsyncStatus): void {
|
|
34
40
|
fs.mkdirSync(asyncDir, { recursive: true });
|
|
@@ -91,8 +97,11 @@ async function readRequest(asyncDir: string): Promise<SteerRequest> {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
function recoveryDescriptor(runId: string): SteeringRecoveryDescriptor {
|
|
100
|
+
const runFanoutBudget = createRunFanoutBudget(runId, 64);
|
|
101
|
+
budgetDirectories.push(runFanoutBudget.directory);
|
|
94
102
|
return {
|
|
95
103
|
version: 1,
|
|
104
|
+
runFanoutBudget,
|
|
96
105
|
sourceRunId: runId,
|
|
97
106
|
agent: "worker-0",
|
|
98
107
|
cwd: os.tmpdir(),
|
|
@@ -61,6 +61,45 @@ describe("classifyTaskMutationIntent", () => {
|
|
|
61
61
|
assert.equal(classifyTaskMutationIntent("worker", "Create a summary").kind, "unknown");
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("does not read hyphenated fix adjectives as the fix verb", () => {
|
|
65
|
+
// Core regression: "must-fix items" in review output specs (fails on old code).
|
|
66
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items").kind, "unknown");
|
|
67
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items.").kind, "unknown");
|
|
68
|
+
assert.equal(classifyTaskMutationIntent("worker", "Review and report MUST-FIX items").kind, "unknown");
|
|
69
|
+
// should-fix with a noun from the pattern's alternation (also fails on old code).
|
|
70
|
+
assert.equal(classifyTaskMutationIntent("worker", "List should-fix tests with severity labels").kind, "unknown");
|
|
71
|
+
// Same class for other verbs: hyphenated adjective + verb.
|
|
72
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return must-edit findings").kind, "unknown");
|
|
73
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return must-update items").kind, "unknown");
|
|
74
|
+
assert.equal(classifyTaskMutationIntent("worker", "List should-add files").kind, "unknown");
|
|
75
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return must-apply changes").kind, "unknown");
|
|
76
|
+
assert.equal(classifyTaskMutationIntent("worker", "List should-make changes").kind, "unknown");
|
|
77
|
+
assert.equal(classifyTaskMutationIntent("worker", "must-do those fixes").kind, "unknown");
|
|
78
|
+
// Dash coverage: U+2010 hyphen, U+2011 non-breaking hyphen, en/em dashes.
|
|
79
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2010fix items").kind, "unknown");
|
|
80
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2011fix items").kind, "unknown");
|
|
81
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2013fix items").kind, "unknown");
|
|
82
|
+
assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2014fix items").kind, "unknown");
|
|
83
|
+
// Clause-level em/en dashes are punctuation, not compounds: "branch—fix it".
|
|
84
|
+
assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2014fix it.").kind, "implementation");
|
|
85
|
+
assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2013fix it.").kind, "implementation");
|
|
86
|
+
assert.equal(taskMayMutate("Inspect the failing branch\u2014fix it."), true);
|
|
87
|
+
// Hyphenated genuine imperatives stay write-capable.
|
|
88
|
+
assert.equal(taskMayMutate("hot-fix the bug"), true);
|
|
89
|
+
assert.equal(taskMayMutate("quick-fix that bug"), true);
|
|
90
|
+
// Genuine imperative usage keeps classifying as implementation.
|
|
91
|
+
assert.equal(classifyTaskMutationIntent("worker", "Go through the list and fix items one by one").kind, "implementation");
|
|
92
|
+
assert.equal(classifyTaskMutationIntent("worker", "fix items on the sprint board").kind, "implementation");
|
|
93
|
+
// CLI flags keep write intent: "--fix" / "-w" are not hyphenated adjectives.
|
|
94
|
+
assert.equal(classifyTaskMutationIntent("worker", "Run eslint --fix code").kind, "implementation");
|
|
95
|
+
assert.equal(taskMayMutate("Run eslint --fix code"), true);
|
|
96
|
+
// "--write" was never a classifier verb (pre-existing unknown); the
|
|
97
|
+
// regression guard is that write-capability survives for acceptance.
|
|
98
|
+
assert.equal(taskMayMutate("Run prettier --write files"), true);
|
|
99
|
+
// Guard-facing mirror: the completion guard must not expect mutation.
|
|
100
|
+
assert.equal(expectsImplementationMutation("worker", "Return a review with the top 2-3 must-fix items"), false);
|
|
101
|
+
});
|
|
102
|
+
|
|
64
103
|
it("expectsImplementationMutation mirrors the classifier", () => {
|
|
65
104
|
assert.equal(expectsImplementationMutation("worker", "Do not modify tests; implement the fix"), true);
|
|
66
105
|
assert.equal(expectsImplementationMutation("worker", "Review the diff and suggest fixes only. Do not edit files."), false);
|