agent-dealer 1.2.3 → 1.2.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/bundle/server/dist/adapters/agent-health.js +25 -2
- package/bundle/server/dist/adapters/agent-health.test.js +55 -2
- package/bundle/server/dist/adapters/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +330 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +222 -63
- package/bundle/server/dist/capacity/claude-local-cache.test.js +179 -30
- package/bundle/server/dist/capacity/muse-host.js +111 -23
- package/bundle/server/dist/capacity/muse-host.test.js +125 -1
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +2 -3
- package/bundle/server/dist/capacity/muse-probe.js +250 -0
- package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
- package/bundle/server/dist/capacity/muse.js +16 -6
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/commands.js +35 -8
- package/bundle/server/dist/coordinator/developer-effect.js +40 -6
- package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
- package/bundle/server/dist/coordinator/human-resolution.js +31 -1
- package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
- package/bundle/server/dist/coordinator/prompts.js +15 -0
- package/bundle/server/dist/coordinator/prompts.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +7 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- package/bundle/server/dist/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +17 -14
- package/bundle/server/dist/routes/runtime-capacity.test.js +11 -3
- package/bundle/server/dist/runners/muse-serve-session.js +7 -6
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +22 -3
- package/dist/doctor.js +46 -12
- package/dist/doctor.test.js +41 -10
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -401,6 +401,91 @@ test("NOT-178: a Muse Code developer without credentials is refused at admission
|
|
|
401
401
|
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
402
402
|
}
|
|
403
403
|
});
|
|
404
|
+
// NOT-277: a Muse Code auto-update is re-validated automatically. A version whose developer
|
|
405
|
+
// shell/write check passes is admitted with no manual step; one that lost it is refused with the
|
|
406
|
+
// versions and capability named; one whose check could not complete is refused, never assumed ok.
|
|
407
|
+
test("NOT-277: a Muse Code update is admitted when capable, refused by name when not", async () => {
|
|
408
|
+
const { clearAgentHealthCaches, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
|
|
409
|
+
const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
|
|
410
|
+
const OLD = "1.3.0-R3401.1";
|
|
411
|
+
const NEW = "1.4.0-R4161.1";
|
|
412
|
+
const BROKEN = "1.4.0-R4302.1";
|
|
413
|
+
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
|
|
414
|
+
const versionFile = path.join(stubDir, "version");
|
|
415
|
+
const stub = path.join(stubDir, "muse");
|
|
416
|
+
fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(versionFile)}\n`);
|
|
417
|
+
fs.chmodSync(stub, 0o755);
|
|
418
|
+
// The real checker's async path runs `gh auth status` too; CI runners have no gh login,
|
|
419
|
+
// so a logged-in stub keeps the `ok: true` assertions about Muse alone.
|
|
420
|
+
const ghStub = path.join(stubDir, "gh");
|
|
421
|
+
fs.writeFileSync(ghStub, "#!/bin/sh\necho 'Logged in to github.com account dealer-test'\nexit 0\n");
|
|
422
|
+
fs.chmodSync(ghStub, 0o755);
|
|
423
|
+
const probed = [];
|
|
424
|
+
setMuseCapabilityProbeForTests(async (version) => {
|
|
425
|
+
probed.push(version);
|
|
426
|
+
if (version === BROKEN)
|
|
427
|
+
return { status: "missing", detail: "probe session completed without running its shell command" };
|
|
428
|
+
if (version === "9.9.9-R1")
|
|
429
|
+
throw new Error("probe session timed out");
|
|
430
|
+
return { status: "capable" };
|
|
431
|
+
});
|
|
432
|
+
const saved = {
|
|
433
|
+
MUSE_CLI: process.env.MUSE_CLI,
|
|
434
|
+
META_API_KEY: process.env.META_API_KEY,
|
|
435
|
+
SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
|
|
436
|
+
PATH: process.env.PATH,
|
|
437
|
+
};
|
|
438
|
+
process.env.MUSE_CLI = stub;
|
|
439
|
+
process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
440
|
+
process.env.META_API_KEY = "k";
|
|
441
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
442
|
+
setAdmissionHealthCheckerForTests(null);
|
|
443
|
+
clearAgentHealthCaches();
|
|
444
|
+
/** Muse reports `version`; the next health read sees it and its one-time check settles. */
|
|
445
|
+
const museReports = async (version) => {
|
|
446
|
+
fs.writeFileSync(versionFile, `Muse Code ${version.split("-")[0]} (${version})\n`);
|
|
447
|
+
await runtimeIssuesUncached("muse_code");
|
|
448
|
+
await settleMuseCapabilityCheckForTests();
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
const issue = readyIssue("muse-updated", { runtimes: { dev: "muse_code", rev: "codex_local" } });
|
|
452
|
+
const developerHealth = () => checkRoleAgentHealthy(issue, "developer", { deckOnline: true });
|
|
453
|
+
await museReports(OLD);
|
|
454
|
+
assert.deepEqual(await developerHealth(), { ok: true });
|
|
455
|
+
await museReports(NEW);
|
|
456
|
+
assert.deepEqual(await developerHealth(), { ok: true });
|
|
457
|
+
assert.deepEqual(probed, [OLD, NEW]);
|
|
458
|
+
await museReports(BROKEN);
|
|
459
|
+
enqueueIssue(issue.id);
|
|
460
|
+
assert.equal(await admitNext(), null);
|
|
461
|
+
assert.equal(getIssue(issue.id).status, "ready");
|
|
462
|
+
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
463
|
+
const reason = getQueuedEntryForIssue(issue.id).waitReason;
|
|
464
|
+
assert.match(reason, /developer unhealthy/);
|
|
465
|
+
assert.match(reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
|
|
466
|
+
await museReports("9.9.9-R1");
|
|
467
|
+
const unverified = await developerHealth();
|
|
468
|
+
assert.equal(unverified.ok, false);
|
|
469
|
+
assert.match(unverified.reason, new RegExp(`Could not verify Muse Code developer shell/write access after version change \\(${BROKEN} → 9\\.9\\.9-R1\\)`));
|
|
470
|
+
assert.deepEqual(probed, [OLD, NEW, BROKEN, "9.9.9-R1"]);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
for (const [key, value] of Object.entries({
|
|
474
|
+
MUSE_CLI: saved.MUSE_CLI,
|
|
475
|
+
META_API_KEY: saved.META_API_KEY,
|
|
476
|
+
AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
|
|
477
|
+
PATH: saved.PATH,
|
|
478
|
+
})) {
|
|
479
|
+
if (value === undefined)
|
|
480
|
+
delete process.env[key];
|
|
481
|
+
else
|
|
482
|
+
process.env[key] = value;
|
|
483
|
+
}
|
|
484
|
+
setMuseCapabilityProbeForTests(null);
|
|
485
|
+
clearAgentHealthCaches();
|
|
486
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
487
|
+
}
|
|
488
|
+
});
|
|
404
489
|
// NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
|
|
405
490
|
// unit-test health skip on, and without probing the CLI.
|
|
406
491
|
test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {
|
|
@@ -28,7 +28,7 @@ import { reasonForWorkerFailedEvent } from "./failure-reason.js";
|
|
|
28
28
|
import { recordCausesForWorkerFailedEvent } from "./failure-cause.js";
|
|
29
29
|
import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
30
30
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
31
|
-
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
31
|
+
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
32
32
|
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
33
33
|
import { externalMergeStateForIssue } from "./external-merge.js";
|
|
34
34
|
import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
|
|
@@ -197,7 +197,7 @@ export function activeWorkflowConflictMessage(issueId) {
|
|
|
197
197
|
* the gate stays on startWorkflow; clearing a stale gate after AC lands lives here so
|
|
198
198
|
* admitNext cannot leave it dangling.
|
|
199
199
|
*/
|
|
200
|
-
export function startWorkflowCore(issueId) {
|
|
200
|
+
export function startWorkflowCore(issueId, opts) {
|
|
201
201
|
const issue = getIssue(issueId);
|
|
202
202
|
if (!issue)
|
|
203
203
|
throw new StartPreconditionError(404, "Issue not found");
|
|
@@ -233,7 +233,12 @@ export function startWorkflowCore(issueId) {
|
|
|
233
233
|
workflowInstanceId: instance.id,
|
|
234
234
|
kind: "developer",
|
|
235
235
|
round: 1,
|
|
236
|
-
payload: {
|
|
236
|
+
payload: {
|
|
237
|
+
profileSnapshot: queuedProfileSnapshot(issue, "developer"),
|
|
238
|
+
// NOT-272: carry the scope decision only on this first round's payload — later
|
|
239
|
+
// rounds never see it (the prompt reads it from here, not from the DB).
|
|
240
|
+
...(opts?.scopeDecisionNote ? { scopeDecisionNote: opts.scopeDecisionNote } : {}),
|
|
241
|
+
},
|
|
237
242
|
idempotencyKey: `${instance.id}:developer:1`,
|
|
238
243
|
});
|
|
239
244
|
// NOT-103: every successful start is a force-admit — keep queue state in sync whether
|
|
@@ -982,10 +987,23 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
982
987
|
return { ok: false, code: 404, error: "Human action not found" };
|
|
983
988
|
if (action.status !== "open")
|
|
984
989
|
return { ok: false, code: 409, error: "Human action already resolved" };
|
|
985
|
-
|
|
990
|
+
// NOT-272: same shape validation as the HTTP route (which already applied it) —
|
|
991
|
+
// defense in depth for direct/CLI callers of this sync entry point.
|
|
992
|
+
const normalizedNote = normalizeResolutionNote(opts?.note);
|
|
993
|
+
if (!normalizedNote.ok)
|
|
994
|
+
return { ok: false, code: 400, error: normalizedNote.error };
|
|
995
|
+
const resolution = parseHumanResolution(action.actionType, choice, normalizedNote.note);
|
|
986
996
|
if (!resolution) {
|
|
987
997
|
return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
|
|
988
998
|
}
|
|
999
|
+
// NOT-272: the typed resolution carries the note only for product_scope_decision
|
|
1000
|
+
// (parseHumanResolution drops it for every other action type).
|
|
1001
|
+
const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
|
|
1002
|
+
? resolution.note
|
|
1003
|
+
: undefined;
|
|
1004
|
+
const storedResolution = scopeDecisionNote
|
|
1005
|
+
? { choice, note: scopeDecisionNote }
|
|
1006
|
+
: { choice };
|
|
989
1007
|
// NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
|
|
990
1008
|
// retry_merge/repair/close only (resume would re-run development on approved work);
|
|
991
1009
|
// every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
|
|
@@ -1077,7 +1095,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1077
1095
|
}
|
|
1078
1096
|
try {
|
|
1079
1097
|
return getDb().transaction(() => {
|
|
1080
|
-
resolveHumanAction(actionId, resolvedBy,
|
|
1098
|
+
resolveHumanAction(actionId, resolvedBy, storedResolution);
|
|
1081
1099
|
appendWorkflowEvent({
|
|
1082
1100
|
issueId: issue.id,
|
|
1083
1101
|
type: "human_action.resolved",
|
|
@@ -1086,7 +1104,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1086
1104
|
stage: issue.status,
|
|
1087
1105
|
payload: { actionType: action.actionType, choice },
|
|
1088
1106
|
});
|
|
1089
|
-
|
|
1107
|
+
// NOT-272: this branch is always product_scope_decision (legacy terminal
|
|
1108
|
+
// actions returned above) — the note rides the round-1 payload so the very
|
|
1109
|
+
// next developer round reads it in its prompt.
|
|
1110
|
+
const { workItem } = startWorkflowCore(issue.id, { scopeDecisionNote: scopeDecisionNote });
|
|
1090
1111
|
return {
|
|
1091
1112
|
ok: true,
|
|
1092
1113
|
issueStatus: getIssue(issue.id).status,
|
|
@@ -1165,7 +1186,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1165
1186
|
: {};
|
|
1166
1187
|
return getDb().transaction(() => {
|
|
1167
1188
|
resolveHumanAction(actionId, resolvedBy, {
|
|
1168
|
-
|
|
1189
|
+
...storedResolution,
|
|
1169
1190
|
// NOT-226: record the re-pin so the resolved action itself shows which head the
|
|
1170
1191
|
// reviewer was re-queued at when the branch had moved while parked.
|
|
1171
1192
|
...(rePinnedResumeHeadSha
|
|
@@ -1285,7 +1306,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1285
1306
|
workflowInstanceId: instance.id,
|
|
1286
1307
|
kind: "developer",
|
|
1287
1308
|
round: issueNow.currentRound,
|
|
1288
|
-
payload: {
|
|
1309
|
+
payload: {
|
|
1310
|
+
profileSnapshot: queuedProfileSnapshot(issue, "developer"),
|
|
1311
|
+
// NOT-272: a product_scope_decision note rides only this next round's
|
|
1312
|
+
// payload — later rounds never see it.
|
|
1313
|
+
...(scopeDecisionNote ? { scopeDecisionNote } : {}),
|
|
1314
|
+
},
|
|
1289
1315
|
idempotencyKey: `${instance.id}:developer:resume:${action.id}`,
|
|
1290
1316
|
});
|
|
1291
1317
|
return {
|
|
@@ -1347,6 +1373,7 @@ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, ch
|
|
|
1347
1373
|
const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, {
|
|
1348
1374
|
resumeLiveHeadSha,
|
|
1349
1375
|
externalMergeState,
|
|
1376
|
+
note: opts?.note,
|
|
1350
1377
|
});
|
|
1351
1378
|
if (!result.ok || !result.pendingMerge)
|
|
1352
1379
|
return result;
|
|
@@ -782,6 +782,11 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
782
782
|
const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
|
|
783
783
|
const guidance = guidanceForNextSession(issue.id, sessionId);
|
|
784
784
|
const retryReason = payload.retryReason ?? undefined;
|
|
785
|
+
// NOT-272: the scope note rides only the work item queued by the resolution —
|
|
786
|
+
// exactly this round reads it; nothing is re-read from the DB.
|
|
787
|
+
const scopeDecisionNote = typeof payload.scopeDecisionNote === "string" && payload.scopeDecisionNote.trim()
|
|
788
|
+
? payload.scopeDecisionNote.trim()
|
|
789
|
+
: undefined;
|
|
785
790
|
let priorConclusion;
|
|
786
791
|
let priorVerificationReceipt;
|
|
787
792
|
if (retryReason) {
|
|
@@ -816,6 +821,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
816
821
|
deckId: isMuse ? null : (snapshot?.deckId ?? null),
|
|
817
822
|
noAgentDeck: isMuse,
|
|
818
823
|
guidance: guidance.length ? guidance : undefined,
|
|
824
|
+
scopeDecisionNote,
|
|
819
825
|
});
|
|
820
826
|
// NOT-83 review finding: the session is marked `running` (worker-loop.ts) before this
|
|
821
827
|
// handler ever runs, so an abort landing during worktree setup/deck-bind above already
|
|
@@ -1286,13 +1292,41 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1286
1292
|
author: "system",
|
|
1287
1293
|
blobPath: spawned.logPath,
|
|
1288
1294
|
});
|
|
1295
|
+
// NOT-275: a clean exit (exitCode 0, not timed out, no usage cap) that leaves
|
|
1296
|
+
// the worktree dirty hit the same escalation as a crash before this — the
|
|
1297
|
+
// session did real ticket-scoped work but never ran `git commit`. Salvage
|
|
1298
|
+
// first (same mechanism as the crash/timeout path above): on success fall
|
|
1299
|
+
// through to push/PR as if the agent had committed itself; only when the
|
|
1300
|
+
// salvage commit itself fails do we escalate dirty_worktree with recovery.
|
|
1289
1301
|
if (!(await isWorktreeClean(worktreePath))) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1302
|
+
const salvaged = await salvageDirtyWorktree(worktreePath, "crash");
|
|
1303
|
+
if (!salvaged.ok) {
|
|
1304
|
+
return {
|
|
1305
|
+
kind: "dirty_worktree",
|
|
1306
|
+
reason: `${reasonForDirtyWorktree(spawned.logPath, runtime)} Auto-commit salvage failed: ${salvaged.reason}`,
|
|
1307
|
+
path: worktreePath,
|
|
1308
|
+
recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
// NOT-172: durable salvage-commit checkpoint at the existing success point.
|
|
1312
|
+
try {
|
|
1313
|
+
emitCheckpointObserved({
|
|
1314
|
+
issueId: issue.id,
|
|
1315
|
+
workflowInstanceId: instance.id,
|
|
1316
|
+
workerSessionId: sessionId,
|
|
1317
|
+
role: "developer",
|
|
1318
|
+
stage,
|
|
1319
|
+
round,
|
|
1320
|
+
kind: "commit",
|
|
1321
|
+
observedSha: salvaged.commitSha,
|
|
1322
|
+
origin: "salvage",
|
|
1323
|
+
inputSha: samplerInputSha,
|
|
1324
|
+
branch: branchName,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
catch {
|
|
1328
|
+
// checkpoint evidence must never fail the attempt itself
|
|
1329
|
+
}
|
|
1296
1330
|
}
|
|
1297
1331
|
const ahead = await commitsAhead({ worktreePath, baseRef: `origin/${baseBranch}` });
|
|
1298
1332
|
if (ahead === 0) {
|
|
@@ -109,11 +109,6 @@ const commitingSpawn = async (input) => {
|
|
|
109
109
|
};
|
|
110
110
|
/** Never touches the worktree — nothing to push, the no_pr outcome. */
|
|
111
111
|
const noopSpawn = async () => ({ exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false });
|
|
112
|
-
/** Leaves an untracked file uncommitted — the dirty_worktree outcome. */
|
|
113
|
-
const dirtySpawn = async (input) => {
|
|
114
|
-
fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
|
|
115
|
-
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
116
|
-
};
|
|
117
112
|
const crashingSpawn = async () => ({ exitCode: 1, transcript: "boom", logPath: "/dev/null", timedOut: false });
|
|
118
113
|
const timedOutSpawn = async () => ({ exitCode: 1, transcript: "", logPath: "/dev/null", timedOut: true });
|
|
119
114
|
/**
|
|
@@ -253,9 +248,95 @@ test("the branch created on a retried round is reused, not re-created — no 'br
|
|
|
253
248
|
assert.match(prompts[1], /\*\*Last failure:\*\* Developer session produced no PR\./);
|
|
254
249
|
assert.doesNotMatch(prompts[1], /fresh branch/);
|
|
255
250
|
});
|
|
256
|
-
test("dirty_worktree: an uncommitted file escalates without consuming a round", async () => {
|
|
251
|
+
test("dirty_worktree: an uncommitted file whose salvage commit fails escalates without consuming a round", async () => {
|
|
252
|
+
// NOT-275: a clean exit (exitCode 0) that leaves the worktree dirty is salvaged
|
|
253
|
+
// first — escalation only happens when the salvage commit itself fails. Force
|
|
254
|
+
// that failure with a rejecting pre-commit hook (same technique as the NOT-145
|
|
255
|
+
// salvage-failure test).
|
|
256
|
+
const issueId = await makeIssue();
|
|
257
|
+
const salvageBlockedSpawn = async (input) => {
|
|
258
|
+
fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
|
|
259
|
+
const common = git(input.cwd, "rev-parse", "--git-common-dir");
|
|
260
|
+
const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
|
|
261
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
262
|
+
const hookPath = path.join(hooks, "pre-commit");
|
|
263
|
+
fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
|
|
264
|
+
fs.chmodSync(hookPath, 0o755);
|
|
265
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
266
|
+
};
|
|
267
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: salvageBlockedSpawn, github: fakeGithub() }));
|
|
268
|
+
startWorkflow(issueId);
|
|
269
|
+
await pump(1);
|
|
270
|
+
const issue = getIssue(issueId);
|
|
271
|
+
assert.equal(issue.status, "needs_human");
|
|
272
|
+
assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
|
|
273
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
|
|
274
|
+
assert.ok(action, "expected a policy_escalation human action");
|
|
275
|
+
assert.match(action.reason, /Auto-commit salvage failed|salvage-blocked/i);
|
|
276
|
+
// NOT-145 / NOT-137: dirty escalations carry path + recovery like worktree_conflict.
|
|
277
|
+
assert.match(action.reason, /Recovery:/);
|
|
278
|
+
assert.match(action.reason, /git status/);
|
|
279
|
+
// Cleanup hook so later tests in this file aren't poisoned (worktrees share
|
|
280
|
+
// .git/hooks via the common dir).
|
|
281
|
+
const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
282
|
+
const leftover = roleWorktreePath(repo, round1.id, "developer");
|
|
283
|
+
assert.ok(fs.existsSync(path.join(leftover, "scratch.txt")), "failed salvage must not wipe WIP");
|
|
284
|
+
const common = git(repo, "rev-parse", "--git-common-dir");
|
|
285
|
+
const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(repo, common, "hooks");
|
|
286
|
+
fs.rmSync(path.join(hooks, "pre-commit"), { force: true });
|
|
287
|
+
fs.rmSync(leftover, { recursive: true, force: true });
|
|
288
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
289
|
+
});
|
|
290
|
+
test("NOT-275: a clean exit that leaves tracked + untracked changes salvages a checkpoint and proceeds to push/PR", async () => {
|
|
291
|
+
// The session exits 0 (not timed out, no usage cap) but never ran `git commit`.
|
|
292
|
+
// The effect must auto-commit the WIP as a salvage checkpoint on the issue
|
|
293
|
+
// branch and continue exactly as if the agent had committed the work itself —
|
|
294
|
+
// it must NOT return kind: "dirty_worktree".
|
|
295
|
+
const issueId = await makeIssue();
|
|
296
|
+
const cleanExitDirtySpawn = async (input) => {
|
|
297
|
+
fs.appendFileSync(path.join(input.cwd, "README.md"), "more docs\n");
|
|
298
|
+
fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented\n");
|
|
299
|
+
return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
|
|
300
|
+
};
|
|
301
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: cleanExitDirtySpawn, github: fakeGithub() }));
|
|
302
|
+
startWorkflow(issueId);
|
|
303
|
+
await pump(1);
|
|
304
|
+
const issue = getIssue(issueId);
|
|
305
|
+
assert.equal(issue.status, "reviewing", "salvaged clean-exit work proceeds to push/PR like a self-committed session");
|
|
306
|
+
assert.equal(issue.currentRound, 1);
|
|
307
|
+
assert.ok(issue.headSha, "push happened — head SHA recorded");
|
|
308
|
+
assert.ok(issue.prNumber, "draft PR opened");
|
|
309
|
+
assert.equal(git(repo, "ls-remote", "origin", `refs/heads/${issue.branch}`).length > 0, true);
|
|
310
|
+
const branch = issueBranchName(issueId);
|
|
311
|
+
assert.equal(git(repo, "log", "-1", "--pretty=%s", branch), "wip: crash salvage");
|
|
312
|
+
assert.ok(git(repo, "show", `${branch}:feature.txt`).includes("implemented"), "untracked WIP is in the salvage tip");
|
|
313
|
+
assert.ok(git(repo, "show", `${branch}:README.md`).includes("more docs"), "tracked WIP is in the salvage tip");
|
|
314
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
315
|
+
assert.equal(dev.status, "done");
|
|
316
|
+
assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"), "no dirty_worktree escalation");
|
|
317
|
+
// Deliverable 1: the salvage path emits the commit checkpoint evidence event.
|
|
318
|
+
const salvageCommits = listWorkflowEventsForIssue(issueId)
|
|
319
|
+
.filter((e) => e.type === "checkpoint.observed")
|
|
320
|
+
.map((e) => JSON.parse(e.payloadJson))
|
|
321
|
+
.filter((p) => p.kind === "commit" && p.origin === "salvage");
|
|
322
|
+
assert.equal(salvageCommits.length, 1, "salvage emits exactly one salvage-origin commit checkpoint");
|
|
323
|
+
assert.equal(salvageCommits[0].observedSha, issue.headSha, "the checkpoint vouches for the salvaged tip");
|
|
324
|
+
});
|
|
325
|
+
test("NOT-275: a clean exit with dirty worktree still returns dirty_worktree when the salvage commit itself fails", async () => {
|
|
257
326
|
const issueId = await makeIssue();
|
|
258
|
-
|
|
327
|
+
const failingSalvageSpawn = async (input) => {
|
|
328
|
+
fs.appendFileSync(path.join(input.cwd, "README.md"), "more docs\n");
|
|
329
|
+
fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented\n");
|
|
330
|
+
// Reject the coordinator's salvage commit so we exercise the escalate-and-preserve path.
|
|
331
|
+
const common = git(input.cwd, "rev-parse", "--git-common-dir");
|
|
332
|
+
const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
|
|
333
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
334
|
+
const hookPath = path.join(hooks, "pre-commit");
|
|
335
|
+
fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
|
|
336
|
+
fs.chmodSync(hookPath, 0o755);
|
|
337
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
338
|
+
};
|
|
339
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: failingSalvageSpawn, github: fakeGithub() }));
|
|
259
340
|
startWorkflow(issueId);
|
|
260
341
|
await pump(1);
|
|
261
342
|
const issue = getIssue(issueId);
|
|
@@ -263,9 +344,18 @@ test("dirty_worktree: an uncommitted file escalates without consuming a round",
|
|
|
263
344
|
assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
|
|
264
345
|
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
|
|
265
346
|
assert.ok(action, "expected a policy_escalation human action");
|
|
266
|
-
|
|
347
|
+
assert.match(action.reason, /Auto-commit salvage failed|salvage-blocked/i);
|
|
267
348
|
assert.match(action.reason, /Recovery:/);
|
|
268
349
|
assert.match(action.reason, /git status/);
|
|
350
|
+
const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
351
|
+
const leftover = roleWorktreePath(repo, round1.id, "developer");
|
|
352
|
+
assert.ok(fs.existsSync(path.join(leftover, "feature.txt")), "failed salvage must not wipe WIP");
|
|
353
|
+
// Cleanup hook so later tests in this file aren't poisoned.
|
|
354
|
+
const common = git(repo, "rev-parse", "--git-common-dir");
|
|
355
|
+
const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(repo, common, "hooks");
|
|
356
|
+
fs.rmSync(path.join(hooks, "pre-commit"), { force: true });
|
|
357
|
+
fs.rmSync(leftover, { recursive: true, force: true });
|
|
358
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
269
359
|
});
|
|
270
360
|
test("session_failed: the agent process exits non-zero — retried like no_pr", async () => {
|
|
271
361
|
const issueId = await makeIssue();
|
|
@@ -924,11 +1014,22 @@ test("NOT-88: a leftover clean worktree from a resolved unpushed_commit escalati
|
|
|
924
1014
|
});
|
|
925
1015
|
test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conflict on every Resume — no infra budget burned, no opaque crash loop", async () => {
|
|
926
1016
|
const issueId = await makeIssue();
|
|
927
|
-
// Round 1 exits 0 but leaves the worktree dirty
|
|
928
|
-
//
|
|
1017
|
+
// Round 1 exits 0 but leaves the worktree dirty with salvage blocked (NOT-275
|
|
1018
|
+
// salvages clean-exit dirt first, so a rejecting pre-commit hook forces the
|
|
1019
|
+
// dirty_worktree escalation that preserves the checkout). That leftover is
|
|
929
1020
|
// the setup NOT-88's collision needs: round 2 must find that SAME dirty leftover still
|
|
930
1021
|
// holding the branch.
|
|
931
|
-
|
|
1022
|
+
const salvageBlockedDirtySpawn = async (input) => {
|
|
1023
|
+
fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
|
|
1024
|
+
const common = git(input.cwd, "rev-parse", "--git-common-dir");
|
|
1025
|
+
const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
|
|
1026
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
1027
|
+
const hookPath = path.join(hooks, "pre-commit");
|
|
1028
|
+
fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
|
|
1029
|
+
fs.chmodSync(hookPath, 0o755);
|
|
1030
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
1031
|
+
};
|
|
1032
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: salvageBlockedDirtySpawn, github: fakeGithub() }));
|
|
932
1033
|
startWorkflow(issueId);
|
|
933
1034
|
await pump(1);
|
|
934
1035
|
const round1Session = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
@@ -950,6 +1051,11 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
|
|
|
950
1051
|
assert.ok(secondAction, "a fresh, actionable escalation — not silence and not a crash");
|
|
951
1052
|
assert.match(secondAction.reason, /uncommitted changes/);
|
|
952
1053
|
assert.match(secondAction.reason, new RegExp(leftoverPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1054
|
+
// Cleanup the salvage-blocking hook too (worktrees share .git/hooks via the
|
|
1055
|
+
// common dir — leaving it would reject commits in every later test).
|
|
1056
|
+
const commonDir = git(repo, "rev-parse", "--git-common-dir");
|
|
1057
|
+
const hooksDir = path.isAbsolute(commonDir) ? path.join(commonDir, "hooks") : path.join(repo, commonDir, "hooks");
|
|
1058
|
+
fs.rmSync(path.join(hooksDir, "pre-commit"), { force: true });
|
|
953
1059
|
fs.rmSync(leftoverPath, { recursive: true, force: true });
|
|
954
1060
|
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
955
1061
|
});
|
|
@@ -1650,7 +1756,10 @@ test("NOT-130: a green suite on a dirty worktree is not persisted as tip evidenc
|
|
|
1650
1756
|
startWorkflow(issueId);
|
|
1651
1757
|
await pump(1);
|
|
1652
1758
|
assert.equal(listArtifactsForIssue(issueId).some((a) => a.kind === "verification_receipt"), false, "dirty tree must not mint a tip-scoped verification receipt");
|
|
1653
|
-
|
|
1759
|
+
// NOT-275: the post-suite dirt is salvaged and the handoff proceeds (the receipt
|
|
1760
|
+
// check above stays the point of this test — the suite ran on a dirty tree, so
|
|
1761
|
+
// no tip-scoped receipt may exist even after the salvaged push/PR).
|
|
1762
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
1654
1763
|
});
|
|
1655
1764
|
test("NOT-130: checks_failed retry does not carry a prior green receipt into the prompt", async () => {
|
|
1656
1765
|
const issueId = await makeIssue();
|
|
@@ -22,6 +22,31 @@ export const PUSH_DIVERGENCE_RESPONSE_OPTIONS = [
|
|
|
22
22
|
{ choice: "resume", label: "Resume development" },
|
|
23
23
|
{ choice: "close", label: "Close" },
|
|
24
24
|
];
|
|
25
|
+
/**
|
|
26
|
+
* NOT-272: cap for the optional human note on a `product_scope_decision` resolution.
|
|
27
|
+
* Introduced here (no route-level text cap convention exists to mirror) — long enough
|
|
28
|
+
* for a real scope decision, short enough to fit verbatim in the next round's prompt.
|
|
29
|
+
*/
|
|
30
|
+
export const PRODUCT_SCOPE_NOTE_MAX_CHARS = 4000;
|
|
31
|
+
/**
|
|
32
|
+
* NOT-272: normalizes the raw `note` body field of POST /api/human-actions/:id/resolve.
|
|
33
|
+
* Absent/blank is "no note" (today's behavior); a present non-string or an over-long
|
|
34
|
+
* string is a caller error. Scoping to `product_scope_decision` happens in
|
|
35
|
+
* `parseHumanResolution` / the resolve path — this only validates the shape.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeResolutionNote(note) {
|
|
38
|
+
if (note === undefined || note === null)
|
|
39
|
+
return { ok: true };
|
|
40
|
+
if (typeof note !== "string")
|
|
41
|
+
return { ok: false, error: "note must be a string" };
|
|
42
|
+
const trimmed = note.trim();
|
|
43
|
+
if (!trimmed)
|
|
44
|
+
return { ok: true };
|
|
45
|
+
if (trimmed.length > PRODUCT_SCOPE_NOTE_MAX_CHARS) {
|
|
46
|
+
return { ok: false, error: `note must be at most ${PRODUCT_SCOPE_NOTE_MAX_CHARS} characters` };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, note: trimmed };
|
|
49
|
+
}
|
|
25
50
|
/**
|
|
26
51
|
* The only choices resolveHumanActionOutcome accepts per action type — the stored "response
|
|
27
52
|
* options." `reflection_interaction_required` and `outbound_delivery_interaction_required`
|
|
@@ -58,7 +83,7 @@ const VALID_CHOICES = {
|
|
|
58
83
|
* Run-scoped, has no Issue/workflow_instance to advance, and its only legal resolver is
|
|
59
84
|
* `resolveOutboundDeliveryAction` (queue/approve-deliver.ts).
|
|
60
85
|
*/
|
|
61
|
-
export function parseHumanResolution(actionType, choice) {
|
|
86
|
+
export function parseHumanResolution(actionType, choice, note) {
|
|
62
87
|
if (actionType === "reflection_interaction_required" || actionType === "outbound_delivery_interaction_required") {
|
|
63
88
|
return null;
|
|
64
89
|
}
|
|
@@ -66,6 +91,11 @@ export function parseHumanResolution(actionType, choice) {
|
|
|
66
91
|
return null;
|
|
67
92
|
if (!VALID_CHOICES[actionType].includes(choice))
|
|
68
93
|
return null;
|
|
94
|
+
// NOT-272: `note` is declared only on the product_scope_decision variant — a note
|
|
95
|
+
// arriving with any other action type is dropped here, never stored or threaded.
|
|
96
|
+
if (actionType === "product_scope_decision" && note) {
|
|
97
|
+
return { actionType, choice, note };
|
|
98
|
+
}
|
|
69
99
|
return { actionType, choice };
|
|
70
100
|
}
|
|
71
101
|
/**
|
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
|
|
24
24
|
import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
|
|
25
|
+
import { museVersionRequiredForWork } from "../adapters/muse-capability.js";
|
|
25
26
|
import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
|
|
26
27
|
import { buildMuseDeveloperInvocation } from "../runners/muse-code-args.js";
|
|
27
28
|
import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
|
|
@@ -159,12 +160,21 @@ function refreshMuseCapacityAfterSession() {
|
|
|
159
160
|
*/
|
|
160
161
|
async function runMuseServeLane(input, model, onAdmitted) {
|
|
161
162
|
const { getMuseCapacityHost } = await import("../capacity/muse-host.js");
|
|
163
|
+
const host = getMuseCapacityHost();
|
|
164
|
+
// NOT-277: admission gated on the capability check of the on-disk binary; a long-lived host can
|
|
165
|
+
// still run an older (possibly broken) build. Serve only on a host known to run the confirmed
|
|
166
|
+
// version (restarted onto it when idle); otherwise the work runs on the checked exec lane.
|
|
167
|
+
const required = museVersionRequiredForWork();
|
|
168
|
+
if (required === null)
|
|
169
|
+
return null;
|
|
170
|
+
if (required !== undefined && !(await host.ensureStartedOnVersion(required)))
|
|
171
|
+
return null;
|
|
162
172
|
// Only this call may fall back: it resolves `admitted:false` (or throws)
|
|
163
173
|
// strictly before any model work starts. Everything below runs after
|
|
164
174
|
// admission — the caller's no-double-execution rule (via onAdmitted)
|
|
165
175
|
// keeps those errors loud instead of falling back to exec.
|
|
166
176
|
const turn = await runMuseServeTurn({
|
|
167
|
-
host
|
|
177
|
+
host,
|
|
168
178
|
prompt: input.prompt,
|
|
169
179
|
model,
|
|
170
180
|
cwd: input.cwd,
|
|
@@ -229,7 +239,7 @@ export async function runMuseDeveloperSession(input) {
|
|
|
229
239
|
// executing the work a second time on the exec lane.
|
|
230
240
|
// `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
|
|
231
241
|
// (operator escape hatch).
|
|
232
|
-
if (museServeLaneEnabled()) {
|
|
242
|
+
if (museServeLaneEnabled() && !input.execLaneOnly) {
|
|
233
243
|
let admitted = false;
|
|
234
244
|
try {
|
|
235
245
|
const served = await runMuseServeLane(input, model, () => {
|
|
@@ -9,6 +9,19 @@ function guidanceSection(guidance) {
|
|
|
9
9
|
...guidance.flatMap((g) => [g.trim(), ``]),
|
|
10
10
|
];
|
|
11
11
|
}
|
|
12
|
+
/** NOT-272: a resolved product_scope_decision's human note, verbatim. Empty/absent
|
|
13
|
+
* renders nothing so noteless resolves produce byte-for-byte the prompt they always did. */
|
|
14
|
+
function scopeDecisionSection(note) {
|
|
15
|
+
if (!note?.trim())
|
|
16
|
+
return [];
|
|
17
|
+
return [
|
|
18
|
+
`## Human decision`,
|
|
19
|
+
`A human resolved the product-scope question that was holding this work. This is the decision — follow it. Do not re-derive it, do not re-escalate the same question, and do not write this decision into source comments; it is recorded on the human action.`,
|
|
20
|
+
``,
|
|
21
|
+
note.trim(),
|
|
22
|
+
``,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
12
25
|
/**
|
|
13
26
|
* An agent with a deckId is launched with that deck already equipped in its MCP session
|
|
14
27
|
* (NOT-106). `bind_workspace` confirms the equipped deck for this session cwd — it does
|
|
@@ -63,6 +76,8 @@ export function buildDeveloperPrompt(input) {
|
|
|
63
76
|
parts.push(...formatVerificationReceiptSection(input.priorVerificationReceipt));
|
|
64
77
|
}
|
|
65
78
|
}
|
|
79
|
+
// NOT-272: the scope decision leads — the developer reads it before the task itself.
|
|
80
|
+
parts.push(...scopeDecisionSection(input.scopeDecisionNote));
|
|
66
81
|
parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria, ``);
|
|
67
82
|
if (input.findings?.length) {
|
|
68
83
|
parts.push(`## Findings to address`);
|
|
@@ -133,6 +133,26 @@ test("no guidance section when there is none", () => {
|
|
|
133
133
|
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1 });
|
|
134
134
|
assert.doesNotMatch(prompt, /## Guidance from the team/);
|
|
135
135
|
});
|
|
136
|
+
// NOT-272: a product_scope_decision note reaches the very next developer round verbatim
|
|
137
|
+
// under its own heading, positioned before the task so it is read before acting.
|
|
138
|
+
test("NOT-272: scope decision note renders verbatim under its own heading before the task", () => {
|
|
139
|
+
const note = "The muse_code runner migration IS in scope for this ticket — proceed with it.";
|
|
140
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 2, scopeDecisionNote: note });
|
|
141
|
+
assert.match(prompt, /## Human decision/);
|
|
142
|
+
assert.ok(prompt.includes(note), "note text must appear verbatim");
|
|
143
|
+
assert.ok(prompt.indexOf("## Human decision") < prompt.indexOf("## Task"), "Human decision section must precede the task");
|
|
144
|
+
assert.match(prompt, /do not re-escalate the same question/i);
|
|
145
|
+
});
|
|
146
|
+
// NOT-272: resolving without a note changes nothing — absent, empty, and blank notes all
|
|
147
|
+
// produce byte-for-byte the prompt built before this change.
|
|
148
|
+
test("NOT-272: no scope decision note produces byte-for-byte the same prompt as before", () => {
|
|
149
|
+
const base = { taskSnapshot, round: 2, findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "It breaks", file: "a.ts", line: 10, status: "open", firstRound: 1, lastRound: 1, issueId: "i" }], guidance: ["Use the new logging util instead."] };
|
|
150
|
+
const without = buildDeveloperPrompt(base);
|
|
151
|
+
assert.doesNotMatch(without, /## Human decision/);
|
|
152
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: undefined }), without);
|
|
153
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: "" }), without);
|
|
154
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: " \n " }), without);
|
|
155
|
+
});
|
|
136
156
|
test("guidance since the last session is surfaced in the reviewer prompt", () => {
|
|
137
157
|
const prompt = buildReviewerPrompt({
|
|
138
158
|
taskSnapshot,
|
|
@@ -17,6 +17,7 @@ import { resolveClaudeBin, resolveCodexBin, resolveCursorBin, resolveMuseBin } f
|
|
|
17
17
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
18
18
|
import { buildDeveloperArgs, buildReviewerArgs } from "./args.js";
|
|
19
19
|
import { assertReviewerReadOnly } from "./permissions.js";
|
|
20
|
+
import { assertWorktreeCwd } from "./worktree-cwd-guard.js";
|
|
20
21
|
import { extractResultTranscript } from "./usage.js";
|
|
21
22
|
import { runMuseDeveloperSession } from "./muse-spawn.js";
|
|
22
23
|
export function developerSessionLogPath(sessionId) {
|
|
@@ -32,6 +33,9 @@ const BIN_FOR = {
|
|
|
32
33
|
muse_code: resolveMuseBin,
|
|
33
34
|
};
|
|
34
35
|
export const realDeveloperSpawn = async (input) => {
|
|
36
|
+
// NOT-273: never spawn a worker outside a managed worktree — throws before
|
|
37
|
+
// any child process exists (the effect reports it as "could not start").
|
|
38
|
+
assertWorktreeCwd(input.cwd);
|
|
35
39
|
if (input.runtime === "muse_code") {
|
|
36
40
|
// Own argv, per-attempt XDG dirs and result parsing; deck/MCP are never wired for Muse.
|
|
37
41
|
return runMuseDeveloperSession({
|
|
@@ -51,6 +55,9 @@ export const realDeveloperSpawn = async (input) => {
|
|
|
51
55
|
* loosens a reviewer's tools can never silently reach a live spawn.
|
|
52
56
|
*/
|
|
53
57
|
export const realReviewerSpawn = async (input) => {
|
|
58
|
+
// NOT-273: same worktree-cwd guard as the developer path — throws before
|
|
59
|
+
// any child process exists.
|
|
60
|
+
assertWorktreeCwd(input.cwd);
|
|
54
61
|
const args = buildReviewerArgs(input.runtime, input.prompt, input.model ?? undefined, input.policy, input.mcpConfigPath, input.effort);
|
|
55
62
|
assertReviewerReadOnly(args, { mcpConfigPath: input.mcpConfigPath, mcpEnv: input.mcpEnv });
|
|
56
63
|
const logPath = input.logPath ?? reviewerSessionLogPath(input.sessionId);
|