agent-dealer 1.2.5 → 1.2.7

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 (75) 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 +3 -4
  4. package/bundle/server/dist/adapters/agent-health.test.js +11 -9
  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/muse-capability.js +51 -25
  8. package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
  9. package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
  10. package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
  11. package/bundle/server/dist/capacity/muse-host.js +34 -32
  12. package/bundle/server/dist/capacity/muse-host.test.js +5 -3
  13. package/bundle/server/dist/coordinator/args.js +4 -3
  14. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  15. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  16. package/bundle/server/dist/coordinator/commands.js +66 -5
  17. package/bundle/server/dist/coordinator/developer-effect.js +88 -10
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
  19. package/bundle/server/dist/coordinator/human-resolution.js +2 -0
  20. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  21. package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
  22. package/bundle/server/dist/coordinator/projection.js +1 -0
  23. package/bundle/server/dist/coordinator/prompts.js +10 -6
  24. package/bundle/server/dist/coordinator/prompts.test.js +16 -8
  25. package/bundle/server/dist/coordinator/routing.js +1 -0
  26. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  27. package/bundle/server/dist/coordinator/spawn.js +2 -1
  28. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  29. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  30. package/bundle/server/dist/repository/human-actions.js +13 -0
  31. package/bundle/server/dist/routes/index.js +11 -8
  32. package/bundle/server/dist/routes/version.js +30 -0
  33. package/bundle/server/dist/routes/version.test.js +21 -0
  34. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  35. package/bundle/server/dist/runners/muse-config.js +1 -1
  36. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  37. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  38. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  39. package/bundle/server/package.json +2 -2
  40. package/bundle/server/static-ui/assets/index-DyAJNyfV.css +1 -0
  41. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +64 -0
  42. package/bundle/server/static-ui/index.html +2 -2
  43. package/bundle/shared/dist/action-presentation.d.ts +51 -0
  44. package/bundle/shared/dist/action-presentation.js +280 -0
  45. package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
  46. package/bundle/shared/dist/action-presentation.test.js +180 -0
  47. package/bundle/shared/dist/attempt-waste.d.ts +2 -2
  48. package/bundle/shared/dist/execution-report.d.ts +2 -2
  49. package/bundle/shared/dist/human-actions.d.ts +12 -12
  50. package/bundle/shared/dist/index.d.ts +41 -40
  51. package/bundle/shared/dist/index.js +1 -0
  52. package/bundle/shared/dist/issues.d.ts +12 -12
  53. package/bundle/shared/dist/outbound-draft.d.ts +6 -6
  54. package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
  55. package/bundle/shared/dist/usage-events.d.ts +2 -2
  56. package/bundle/shared/dist/worker-sessions.d.ts +6 -6
  57. package/bundle/shared/dist/workflow.d.ts +2 -2
  58. package/bundle/shared/package.json +1 -1
  59. package/dist/doctor.js +4 -1
  60. package/dist/managed/index.d.ts +1 -1
  61. package/dist/managed/index.js +1 -1
  62. package/dist/managed/updater.d.ts +16 -6
  63. package/dist/managed/updater.js +28 -11
  64. package/dist/managed-update-restart.test.d.ts +1 -0
  65. package/dist/managed-update-restart.test.js +227 -0
  66. package/dist/ports.d.ts +6 -0
  67. package/dist/ports.js +10 -0
  68. package/dist/runtime-state.d.ts +10 -0
  69. package/dist/runtime-state.js +26 -0
  70. package/dist/start.js +67 -2
  71. package/dist/status.js +30 -2
  72. package/dist/update-check.js +3 -6
  73. package/package.json +1 -1
  74. package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
  75. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +0 -60
@@ -1035,6 +1035,7 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
1035
1035
  const round1Session = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
1036
1036
  const leftoverPath = roleWorktreePath(repo, round1Session.id, "developer");
1037
1037
  assert.ok(fs.existsSync(path.join(leftoverPath, "scratch.txt")), "the dirty leftover must be preserved");
1038
+ const statusBeforeResume = git(leftoverPath, "status", "--porcelain");
1038
1039
  const firstAction = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation" && a.status === "open");
1039
1040
  assert.ok(firstAction);
1040
1041
  // Resume without actually cleaning the leftover: must re-detect the same conflict and
@@ -1051,6 +1052,20 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
1051
1052
  assert.ok(secondAction, "a fresh, actionable escalation — not silence and not a crash");
1052
1053
  assert.match(secondAction.reason, /uncommitted changes/);
1053
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");
1054
1069
  // Cleanup the salvage-blocking hook too (worktrees share .git/hooks via the
1055
1070
  // common dir — leaving it would reject commits in every later test).
1056
1071
  const commonDir = git(repo, "rev-parse", "--git-common-dir");
@@ -1059,6 +1074,161 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
1059
1074
  fs.rmSync(leftoverPath, { recursive: true, force: true });
1060
1075
  await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
1061
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
+ });
1062
1232
  test("baseSha is resolved against the fetched base ref, not a stale local branch", async () => {
1063
1233
  // A review round found mergeBase used the local `main`, never fetched — if origin/main
1064
1234
  // had moved since this repo checkout last fetched, the recorded baseSha would be the
@@ -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