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.
Files changed (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. 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
- registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: dirtySpawn, github: fakeGithub() }));
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
- // NOT-145 / NOT-137: exit-0 dirty escalations carry path + recovery like worktree_conflict.
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 — dirty_worktree escalation preserves the
928
- // checkout (NOT-145 salvage applies only to timeout/crash infra deaths). That leftover is
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
- registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: dirtySpawn, github: fakeGithub() }));
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
- assert.equal(getIssue(issueId).status, "needs_human");
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
  /**