agent-dealer 1.2.4 → 1.2.6
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-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- 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 +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +9 -1
- 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/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -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 +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- 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-CoDzZcsz.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 +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -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,23 @@ 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).
|
|
257
256
|
const issueId = await makeIssue();
|
|
258
|
-
|
|
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() }));
|
|
259
268
|
startWorkflow(issueId);
|
|
260
269
|
await pump(1);
|
|
261
270
|
const issue = getIssue(issueId);
|
|
@@ -263,9 +272,90 @@ test("dirty_worktree: an uncommitted file escalates without consuming a round",
|
|
|
263
272
|
assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
|
|
264
273
|
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
|
|
265
274
|
assert.ok(action, "expected a policy_escalation human action");
|
|
266
|
-
|
|
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.
|
|
267
277
|
assert.match(action.reason, /Recovery:/);
|
|
268
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 () => {
|
|
326
|
+
const issueId = await makeIssue();
|
|
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() }));
|
|
340
|
+
startWorkflow(issueId);
|
|
341
|
+
await pump(1);
|
|
342
|
+
const issue = getIssue(issueId);
|
|
343
|
+
assert.equal(issue.status, "needs_human");
|
|
344
|
+
assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
|
|
345
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
|
|
346
|
+
assert.ok(action, "expected a policy_escalation human action");
|
|
347
|
+
assert.match(action.reason, /Auto-commit salvage failed|salvage-blocked/i);
|
|
348
|
+
assert.match(action.reason, /Recovery:/);
|
|
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,16 +1014,28 @@ 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");
|
|
935
1036
|
const leftoverPath = roleWorktreePath(repo, round1Session.id, "developer");
|
|
936
1037
|
assert.ok(fs.existsSync(path.join(leftoverPath, "scratch.txt")), "the dirty leftover must be preserved");
|
|
1038
|
+
const statusBeforeResume = git(leftoverPath, "status", "--porcelain");
|
|
937
1039
|
const firstAction = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation" && a.status === "open");
|
|
938
1040
|
assert.ok(firstAction);
|
|
939
1041
|
// Resume without actually cleaning the leftover: must re-detect the same conflict and
|
|
@@ -950,9 +1052,183 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
|
|
|
950
1052
|
assert.ok(secondAction, "a fresh, actionable escalation — not silence and not a crash");
|
|
951
1053
|
assert.match(secondAction.reason, /uncommitted changes/);
|
|
952
1054
|
assert.match(secondAction.reason, new RegExp(leftoverPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1055
|
+
// NOT-280: the dead predecessor's dirt was salvage-attempted; the escalation names the
|
|
1056
|
+
// actual git failure, and the checkout is left exactly as found.
|
|
1057
|
+
assert.match(secondAction.reason, /Automatic salvage/);
|
|
1058
|
+
assert.match(secondAction.reason, /git .*commit .*failed: .*salvage-blocked/s);
|
|
1059
|
+
assert.equal(git(leftoverPath, "status", "--porcelain"), statusBeforeResume, "index and files left as found");
|
|
1060
|
+
// NOT-280: a second Resume with nothing changed lands on the SAME action — reopened,
|
|
1061
|
+
// never an identical copy.
|
|
1062
|
+
resolveHumanActionAndAdvance(secondAction.id, "test", "resume");
|
|
1063
|
+
await pump(1);
|
|
1064
|
+
assert.equal(getIssue(issueId).status, "needs_human");
|
|
1065
|
+
const afterRepeat = listHumanActionsForIssue(issueId);
|
|
1066
|
+
assert.equal(afterRepeat.length, 2, "no duplicate action for an unchanged blocker");
|
|
1067
|
+
const reopened = afterRepeat.find((a) => a.id === secondAction.id);
|
|
1068
|
+
assert.equal(reopened.status, "open");
|
|
1069
|
+
// Cleanup the salvage-blocking hook too (worktrees share .git/hooks via the
|
|
1070
|
+
// common dir — leaving it would reject commits in every later test).
|
|
1071
|
+
const commonDir = git(repo, "rev-parse", "--git-common-dir");
|
|
1072
|
+
const hooksDir = path.isAbsolute(commonDir) ? path.join(commonDir, "hooks") : path.join(repo, commonDir, "hooks");
|
|
1073
|
+
fs.rmSync(path.join(hooksDir, "pre-commit"), { force: true });
|
|
953
1074
|
fs.rmSync(leftoverPath, { recursive: true, force: true });
|
|
954
1075
|
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
955
1076
|
});
|
|
1077
|
+
test("NOT-280: Resume salvages a dead predecessor's dirty managed checkout once, records one checkpoint, and continues from that tip — no worktree_conflict", async () => {
|
|
1078
|
+
const issueId = await makeIssue();
|
|
1079
|
+
const branch = issueBranchName(issueId);
|
|
1080
|
+
const commonDir = git(repo, "rev-parse", "--git-common-dir");
|
|
1081
|
+
const hooksDir = path.isAbsolute(commonDir) ? path.join(commonDir, "hooks") : path.join(repo, commonDir, "hooks");
|
|
1082
|
+
// Round 1 = the older backend: ticket-scoped edits (tracked + untracked), no commit, and
|
|
1083
|
+
// its own session-end salvage blocked — so the dirty checkout is preserved.
|
|
1084
|
+
let call = 0;
|
|
1085
|
+
const heads = [];
|
|
1086
|
+
const spawn = async (input) => {
|
|
1087
|
+
call++;
|
|
1088
|
+
if (call === 1) {
|
|
1089
|
+
fs.appendFileSync(path.join(input.cwd, "README.md"), "ticket edit\n");
|
|
1090
|
+
fs.writeFileSync(path.join(input.cwd, "widget.txt"), "widget\n");
|
|
1091
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1092
|
+
fs.writeFileSync(path.join(hooksDir, "pre-commit"), "#!/bin/sh\necho blocked >&2\nexit 1\n", { mode: 0o755 });
|
|
1093
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
1094
|
+
}
|
|
1095
|
+
heads.push(git(input.cwd, "rev-parse", "HEAD"));
|
|
1096
|
+
return noopSpawn(input);
|
|
1097
|
+
};
|
|
1098
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn, github: fakeGithub() }));
|
|
1099
|
+
startWorkflow(issueId);
|
|
1100
|
+
await pump(1);
|
|
1101
|
+
assert.equal(getIssue(issueId).status, "needs_human");
|
|
1102
|
+
const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
1103
|
+
const leftoverPath = roleWorktreePath(repo, round1.id, "developer");
|
|
1104
|
+
const tipBefore = git(leftoverPath, "rev-parse", "HEAD");
|
|
1105
|
+
// The hook is gone now (the environment that blocked the old backend changed); the
|
|
1106
|
+
// dirty checkout still holds the branch. Resume must salvage it — not escalate.
|
|
1107
|
+
fs.rmSync(path.join(hooksDir, "pre-commit"), { force: true });
|
|
1108
|
+
const firstAction = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
1109
|
+
resolveHumanActionAndAdvance(firstAction.id, "test", "resume");
|
|
1110
|
+
await pump(1);
|
|
1111
|
+
const issue = getIssue(issueId);
|
|
1112
|
+
assert.equal(issue.status, "reviewing", "the pending attempt continued from the saved tip and handed off");
|
|
1113
|
+
assert.equal(issue.currentRound, 1, "no review round spent");
|
|
1114
|
+
const actions = listHumanActionsForIssue(issueId);
|
|
1115
|
+
assert.equal(actions.length, 1, "no worktree_conflict human action");
|
|
1116
|
+
// Exactly one salvage commit carrying both changes, and the attempt started on it.
|
|
1117
|
+
const salvageSha = heads[0];
|
|
1118
|
+
assert.equal(git(repo, "rev-list", "--count", `${tipBefore}..${salvageSha}`), "1");
|
|
1119
|
+
assert.equal(git(repo, "log", "-1", "--format=%s", salvageSha), "wip: resume salvage");
|
|
1120
|
+
assert.deepEqual(git(repo, "show", "--name-only", "--format=", salvageSha).split("\n").sort(), ["README.md", "widget.txt"]);
|
|
1121
|
+
assert.equal(git(remote, "rev-parse", branch), salvageSha, "the salvaged tip is what was published");
|
|
1122
|
+
const checkpoints = listWorkflowEventsForIssue(issueId).filter((e) => {
|
|
1123
|
+
if (e.type !== "checkpoint.observed")
|
|
1124
|
+
return false;
|
|
1125
|
+
const p = JSON.parse(e.payloadJson ?? "{}");
|
|
1126
|
+
return p.origin === "salvage" && p.observedSha === salvageSha;
|
|
1127
|
+
});
|
|
1128
|
+
assert.equal(checkpoints.length, 1, "one durable salvage checkpoint");
|
|
1129
|
+
assert.equal(checkpoints[0].workerSessionId, round1.id, "tied to the predecessor session");
|
|
1130
|
+
});
|
|
1131
|
+
test("NOT-280: a salvage checkpoint that cannot be persisted blocks before spawning, keeps the checkout, and never duplicates its action", async () => {
|
|
1132
|
+
const issueId = await makeIssue();
|
|
1133
|
+
const commonDir = git(repo, "rev-parse", "--git-common-dir");
|
|
1134
|
+
const hooksDir = path.isAbsolute(commonDir) ? path.join(commonDir, "hooks") : path.join(repo, commonDir, "hooks");
|
|
1135
|
+
let call = 0;
|
|
1136
|
+
const heads = [];
|
|
1137
|
+
const spawn = async (input) => {
|
|
1138
|
+
call++;
|
|
1139
|
+
if (call === 1) {
|
|
1140
|
+
fs.writeFileSync(path.join(input.cwd, "persist.txt"), "ticket work\n");
|
|
1141
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1142
|
+
fs.writeFileSync(path.join(hooksDir, "pre-commit"), "#!/bin/sh\necho blocked >&2\nexit 1\n", { mode: 0o755 });
|
|
1143
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
1144
|
+
}
|
|
1145
|
+
heads.push(git(input.cwd, "rev-parse", "HEAD"));
|
|
1146
|
+
return noopSpawn(input);
|
|
1147
|
+
};
|
|
1148
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn, github: fakeGithub() }));
|
|
1149
|
+
startWorkflow(issueId);
|
|
1150
|
+
await pump(1);
|
|
1151
|
+
const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
1152
|
+
const leftoverPath = roleWorktreePath(repo, round1.id, "developer");
|
|
1153
|
+
const tipBefore = git(leftoverPath, "rev-parse", "HEAD");
|
|
1154
|
+
fs.rmSync(path.join(hooksDir, "pre-commit"), { force: true });
|
|
1155
|
+
// The Dealer database rejects the resume-salvage checkpoint row.
|
|
1156
|
+
getDb().exec(`CREATE TRIGGER not280_block_salvage_checkpoint BEFORE INSERT ON workflow_events
|
|
1157
|
+
WHEN NEW.idempotency_key LIKE 'checkpoint:resume-salvage:%'
|
|
1158
|
+
BEGIN SELECT RAISE(ABORT, 'checkpoint store unavailable'); END`);
|
|
1159
|
+
try {
|
|
1160
|
+
const firstAction = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
1161
|
+
resolveHumanActionAndAdvance(firstAction.id, "test", "resume");
|
|
1162
|
+
await pump(1);
|
|
1163
|
+
assert.equal(getIssue(issueId).status, "needs_human", "no attempt proceeds without its durable checkpoint");
|
|
1164
|
+
assert.equal(heads.length, 0, "nothing spawned");
|
|
1165
|
+
const blocker = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
1166
|
+
assert.match(blocker.reason, /durable checkpoint could not be recorded/);
|
|
1167
|
+
assert.match(blocker.reason, /checkpoint store unavailable/);
|
|
1168
|
+
assert.ok(blocker.reason.includes(leftoverPath));
|
|
1169
|
+
const salvageSha = git(leftoverPath, "rev-parse", "HEAD");
|
|
1170
|
+
assert.equal(git(repo, "rev-list", "--count", `${tipBefore}..${salvageSha}`), "1", "the salvage commit is kept");
|
|
1171
|
+
assert.equal(git(leftoverPath, "status", "--porcelain"), "", "checkout left intact on its salvage tip");
|
|
1172
|
+
// Unchanged blocker: Resume reuses the same action, and never re-commits.
|
|
1173
|
+
const countBefore = listHumanActionsForIssue(issueId).length;
|
|
1174
|
+
resolveHumanActionAndAdvance(blocker.id, "test", "resume");
|
|
1175
|
+
await pump(1);
|
|
1176
|
+
assert.equal(listHumanActionsForIssue(issueId).length, countBefore, "no duplicate action");
|
|
1177
|
+
assert.equal(git(leftoverPath, "rev-parse", "HEAD"), salvageSha, "no duplicate commit");
|
|
1178
|
+
// Store fixed: the next Resume records the checkpoint and continues from the saved tip.
|
|
1179
|
+
getDb().exec("DROP TRIGGER not280_block_salvage_checkpoint");
|
|
1180
|
+
const reopened = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
1181
|
+
resolveHumanActionAndAdvance(reopened.id, "test", "resume");
|
|
1182
|
+
await pump(1);
|
|
1183
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
1184
|
+
assert.equal(heads[0], salvageSha);
|
|
1185
|
+
const checkpoints = listWorkflowEventsForIssue(issueId).filter((e) => {
|
|
1186
|
+
if (e.type !== "checkpoint.observed")
|
|
1187
|
+
return false;
|
|
1188
|
+
const p = JSON.parse(e.payloadJson ?? "{}");
|
|
1189
|
+
return p.origin === "salvage" && p.observedSha === salvageSha;
|
|
1190
|
+
});
|
|
1191
|
+
assert.equal(checkpoints.length, 1, "exactly one durable salvage checkpoint once the store recovers");
|
|
1192
|
+
assert.equal(checkpoints[0].workerSessionId, round1.id);
|
|
1193
|
+
}
|
|
1194
|
+
finally {
|
|
1195
|
+
getDb().exec("DROP TRIGGER IF EXISTS not280_block_salvage_checkpoint");
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
test("NOT-280: a restart after the salvage commit (before cleanup) detects the saved tip — no duplicate commit or checkpoint", async () => {
|
|
1199
|
+
const issueId = await makeIssue();
|
|
1200
|
+
const branch = issueBranchName(issueId);
|
|
1201
|
+
// A dead predecessor's checkout whose resume salvage already landed — the state a
|
|
1202
|
+
// coordinator crash between the salvage commit and the attempt leaves behind.
|
|
1203
|
+
git(repo, "branch", branch, "main");
|
|
1204
|
+
const predecessor = createWorkerSession({
|
|
1205
|
+
issueId,
|
|
1206
|
+
role: "developer",
|
|
1207
|
+
round: 1,
|
|
1208
|
+
agentId: getIssue(issueId).developerAgentId,
|
|
1209
|
+
runtime: "claude_code",
|
|
1210
|
+
});
|
|
1211
|
+
const leftoverPath = roleWorktreePath(repo, predecessor.id, "developer");
|
|
1212
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "add", "-q", leftoverPath, branch], { cwd: repo }));
|
|
1213
|
+
fs.writeFileSync(path.join(leftoverPath, "saved.txt"), "work\n");
|
|
1214
|
+
git(leftoverPath, "add", "-A");
|
|
1215
|
+
git(leftoverPath, "-c", "user.email=agent-dealer@localhost", "-c", "user.name=Agent Dealer", "commit", "-q", "-m", "wip: resume salvage");
|
|
1216
|
+
const salvageSha = git(leftoverPath, "rev-parse", "HEAD");
|
|
1217
|
+
const heads = [];
|
|
1218
|
+
const spawn = async (input) => {
|
|
1219
|
+
heads.push(git(input.cwd, "rev-parse", "HEAD"));
|
|
1220
|
+
return noopSpawn(input);
|
|
1221
|
+
};
|
|
1222
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn, github: fakeGithub() }));
|
|
1223
|
+
startWorkflow(issueId);
|
|
1224
|
+
await pump(1);
|
|
1225
|
+
assert.equal(heads[0], salvageSha, "continued from the saved tip, not a new commit");
|
|
1226
|
+
assert.equal(git(repo, "rev-parse", `refs/heads/${branch}`), salvageSha);
|
|
1227
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
1228
|
+
const checkpoints = listWorkflowEventsForIssue(issueId).filter((e) => e.type === "checkpoint.observed" && JSON.parse(e.payloadJson ?? "{}").origin === "salvage" && JSON.parse(e.payloadJson ?? "{}").observedSha === salvageSha);
|
|
1229
|
+
assert.equal(checkpoints.length, 1);
|
|
1230
|
+
assert.equal(checkpoints[0].workerSessionId, predecessor.id);
|
|
1231
|
+
});
|
|
956
1232
|
test("baseSha is resolved against the fetched base ref, not a stale local branch", async () => {
|
|
957
1233
|
// A review round found mergeBase used the local `main`, never fetched — if origin/main
|
|
958
1234
|
// had moved since this repo checkout last fetched, the recorded baseSha would be the
|
|
@@ -1650,7 +1926,10 @@ test("NOT-130: a green suite on a dirty worktree is not persisted as tip evidenc
|
|
|
1650
1926
|
startWorkflow(issueId);
|
|
1651
1927
|
await pump(1);
|
|
1652
1928
|
assert.equal(listArtifactsForIssue(issueId).some((a) => a.kind === "verification_receipt"), false, "dirty tree must not mint a tip-scoped verification receipt");
|
|
1653
|
-
|
|
1929
|
+
// NOT-275: the post-suite dirt is salvaged and the handoff proceeds (the receipt
|
|
1930
|
+
// check above stays the point of this test — the suite ran on a dirty tree, so
|
|
1931
|
+
// no tip-scoped receipt may exist even after the salvaged push/PR).
|
|
1932
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
1654
1933
|
});
|
|
1655
1934
|
test("NOT-130: checks_failed retry does not carry a prior green receipt into the prompt", async () => {
|
|
1656
1935
|
const issueId = await makeIssue();
|
|
@@ -12,6 +12,8 @@ export const MERGE_FAILURE_RESPONSE_OPTIONS = [
|
|
|
12
12
|
export const MERGE_FAILURE_EVIDENCE_KEY = "mergeFailure";
|
|
13
13
|
/** Evidence key marking a policy_escalation as a NOT-221 diverged-push escalation. */
|
|
14
14
|
export const PUSH_DIVERGENCE_EVIDENCE_KEY = "pushDivergence";
|
|
15
|
+
/** NOT-280: evidence key holding a worktree blocker's `{ fingerprint }` for action dedupe. */
|
|
16
|
+
export const WORKTREE_BLOCKER_EVIDENCE_KEY = "worktreeBlocker";
|
|
15
17
|
/**
|
|
16
18
|
* NOT-221: the stored response options for a diverged-push policy_escalation. The label
|
|
17
19
|
* names the recovery so the dashboard button is self-explanatory next to the SHAs the
|
|
@@ -22,6 +24,31 @@ export const PUSH_DIVERGENCE_RESPONSE_OPTIONS = [
|
|
|
22
24
|
{ choice: "resume", label: "Resume development" },
|
|
23
25
|
{ choice: "close", label: "Close" },
|
|
24
26
|
];
|
|
27
|
+
/**
|
|
28
|
+
* NOT-272: cap for the optional human note on a `product_scope_decision` resolution.
|
|
29
|
+
* Introduced here (no route-level text cap convention exists to mirror) — long enough
|
|
30
|
+
* for a real scope decision, short enough to fit verbatim in the next round's prompt.
|
|
31
|
+
*/
|
|
32
|
+
export const PRODUCT_SCOPE_NOTE_MAX_CHARS = 4000;
|
|
33
|
+
/**
|
|
34
|
+
* NOT-272: normalizes the raw `note` body field of POST /api/human-actions/:id/resolve.
|
|
35
|
+
* Absent/blank is "no note" (today's behavior); a present non-string or an over-long
|
|
36
|
+
* string is a caller error. Scoping to `product_scope_decision` happens in
|
|
37
|
+
* `parseHumanResolution` / the resolve path — this only validates the shape.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeResolutionNote(note) {
|
|
40
|
+
if (note === undefined || note === null)
|
|
41
|
+
return { ok: true };
|
|
42
|
+
if (typeof note !== "string")
|
|
43
|
+
return { ok: false, error: "note must be a string" };
|
|
44
|
+
const trimmed = note.trim();
|
|
45
|
+
if (!trimmed)
|
|
46
|
+
return { ok: true };
|
|
47
|
+
if (trimmed.length > PRODUCT_SCOPE_NOTE_MAX_CHARS) {
|
|
48
|
+
return { ok: false, error: `note must be at most ${PRODUCT_SCOPE_NOTE_MAX_CHARS} characters` };
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, note: trimmed };
|
|
51
|
+
}
|
|
25
52
|
/**
|
|
26
53
|
* The only choices resolveHumanActionOutcome accepts per action type — the stored "response
|
|
27
54
|
* options." `reflection_interaction_required` and `outbound_delivery_interaction_required`
|
|
@@ -58,7 +85,7 @@ const VALID_CHOICES = {
|
|
|
58
85
|
* Run-scoped, has no Issue/workflow_instance to advance, and its only legal resolver is
|
|
59
86
|
* `resolveOutboundDeliveryAction` (queue/approve-deliver.ts).
|
|
60
87
|
*/
|
|
61
|
-
export function parseHumanResolution(actionType, choice) {
|
|
88
|
+
export function parseHumanResolution(actionType, choice, note) {
|
|
62
89
|
if (actionType === "reflection_interaction_required" || actionType === "outbound_delivery_interaction_required") {
|
|
63
90
|
return null;
|
|
64
91
|
}
|
|
@@ -66,6 +93,11 @@ export function parseHumanResolution(actionType, choice) {
|
|
|
66
93
|
return null;
|
|
67
94
|
if (!VALID_CHOICES[actionType].includes(choice))
|
|
68
95
|
return null;
|
|
96
|
+
// NOT-272: `note` is declared only on the product_scope_decision variant — a note
|
|
97
|
+
// arriving with any other action type is dropped here, never stored or threaded.
|
|
98
|
+
if (actionType === "product_scope_decision" && note) {
|
|
99
|
+
return { actionType, choice, note };
|
|
100
|
+
}
|
|
69
101
|
return { actionType, choice };
|
|
70
102
|
}
|
|
71
103
|
/**
|