agent-dealer 1.2.7 → 1.2.9

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 (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -4,6 +4,10 @@ import assert from "node:assert/strict";
4
4
  import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
5
5
  const REVIEW_ROUNDS_LEFT = { currentRound: 1, maxReviewRounds: 3, infraAttempts: 0, maxInfraAttempts: 3 };
6
6
  const REVIEW_AT_LIMIT = { currentRound: 3, maxReviewRounds: 3, infraAttempts: 0, maxInfraAttempts: 3 };
7
+ // NOT-313: CI-repair budget fixtures — infra counters stay at 0/3 throughout so
8
+ // every CI test below also proves the infra budget is untouched, and vice versa.
9
+ const CI_BUDGET_LEFT = { currentRound: 1, maxReviewRounds: 3, infraAttempts: 0, maxInfraAttempts: 3, ciAttempts: 1, maxCiAttempts: 3 };
10
+ const CI_AT_LIMIT = { currentRound: 1, maxReviewRounds: 3, infraAttempts: 0, maxInfraAttempts: 3, ciAttempts: 3, maxCiAttempts: 3 };
7
11
  const INFRA_ATTEMPTS_LEFT = { currentRound: 1, maxReviewRounds: 3, infraAttempts: 1, maxInfraAttempts: 3 };
8
12
  const INFRA_AT_LIMIT = { currentRound: 1, maxReviewRounds: 3, infraAttempts: 3, maxInfraAttempts: 3 };
9
13
  const PINNED_HEAD = "pinned-head-sha";
@@ -181,6 +185,24 @@ test("a failed base fetch defers instead of spending an infra attempt (NOT-197)"
181
185
  });
182
186
  }
183
187
  });
188
+ test("pending CI defers instead of spending an infra attempt (NOT-311)", () => {
189
+ const outcome = {
190
+ kind: "checks_pending",
191
+ reason: "Waiting for CI (running) - no attempt spent",
192
+ branch: "issue-1",
193
+ prNumber: 7,
194
+ headSha: "abc123",
195
+ waitStartedAt: new Date(0).toISOString(),
196
+ };
197
+ // Even with the infra budget already exhausted it waits — the session ended
198
+ // cleanly, so there is no failure to charge and no review handoff to make.
199
+ for (const limits of [INFRA_ATTEMPTS_LEFT, INFRA_AT_LIMIT]) {
200
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, limits), {
201
+ next: "defer_work",
202
+ reason: "Waiting for CI (running) - no attempt spent",
203
+ });
204
+ }
205
+ });
184
206
  test("adapter failure after push retries publish only (no full developer session)", () => {
185
207
  const outcome = {
186
208
  kind: "adapter_failure",
@@ -216,43 +238,81 @@ test("timed_out with infra attempts remaining retries (same bucket as session_fa
216
238
  reason: "Developer session timed out.",
217
239
  });
218
240
  });
219
- test("checks_failed with infra attempts remaining retries (same bucket as session_failed/no_pr)", () => {
220
- const outcome = { kind: "checks_failed" };
241
+ test("NOT-307: an idle-watchdog timed_out routes exactly like a wall-clock timeout", () => {
242
+ // The idle kill reads as timed_out downstream: same bounded infra retry, same
243
+ // budget accounting — only the reason (and the failure cause) names the stall.
244
+ const outcome = {
245
+ kind: "timed_out",
246
+ reason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
247
+ };
221
248
  assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
222
249
  next: "retry_developer",
223
- reason: "Developer's PR checks failed.",
250
+ reason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
251
+ });
252
+ assert.deepStrictEqual(routeDeveloperOutcome({ kind: "timed_out" }, INFRA_AT_LIMIT).next, "human_action");
253
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_AT_LIMIT).next, "human_action");
254
+ });
255
+ test("NOT-313: checks_failed retries on the CI budget with the attempt number, not the infra bucket", () => {
256
+ const outcome = { kind: "checks_failed" };
257
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, REVIEW_ROUNDS_LEFT), {
258
+ next: "retry_developer",
259
+ reason: "CI repair attempt 1 of 3: Developer's PR checks failed.",
260
+ budget: "ci",
261
+ });
262
+ // One CI attempt already spent → the next retry is attempt 2, infra untouched.
263
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, CI_BUDGET_LEFT), {
264
+ next: "retry_developer",
265
+ reason: "CI repair attempt 2 of 3: Developer's PR checks failed.",
266
+ budget: "ci",
224
267
  });
225
268
  });
226
269
  test("NOT-252: checks_failed with enrichment details retries with the enriched reason, not the generic one", () => {
227
270
  const details = "Developer's PR checks failed at abc123: build.\nFailed checks (PR #7 @ abc123):\n- build (CI · failure)";
228
271
  const outcome = { kind: "checks_failed", details };
229
- assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
272
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, CI_BUDGET_LEFT), {
230
273
  next: "retry_developer",
231
- reason: details,
274
+ reason: `CI repair attempt 2 of 3: ${details}`,
275
+ budget: "ci",
232
276
  });
233
277
  });
234
278
  test("NOT-252: checks_failed with blank details falls back to exactly the generic reason", () => {
235
279
  for (const outcome of [{ kind: "checks_failed", details: " " }, { kind: "checks_failed" }]) {
236
- assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
280
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, CI_BUDGET_LEFT), {
237
281
  next: "retry_developer",
238
- reason: "Developer's PR checks failed.",
282
+ reason: "CI repair attempt 2 of 3: Developer's PR checks failed.",
283
+ budget: "ci",
239
284
  });
240
285
  }
241
286
  });
242
- test("checks_failed at the infra-attempt limit escalates", () => {
287
+ test("NOT-313: checks_failed at the CI limit escalates with a CI-specific reason, infra budget untouched", () => {
243
288
  const outcome = { kind: "checks_failed" };
244
- const result = routeDeveloperOutcome(outcome, INFRA_AT_LIMIT);
289
+ const result = routeDeveloperOutcome(outcome, CI_AT_LIMIT);
245
290
  assert.equal(result.next, "human_action");
246
291
  assert.equal(result.actionType, "policy_escalation");
292
+ const reason = result.reason;
293
+ assert.match(reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
294
+ assert.doesNotMatch(reason, /infra-attempt limit reached/);
247
295
  });
248
- test("NOT-252: checks_failed escalation at the limit carries the enriched reason", () => {
296
+ test("NOT-252: checks_failed escalation at the limit carries the failing check names", () => {
249
297
  const details = "Developer's PR checks failed at abc123: build.";
250
298
  const outcome = { kind: "checks_failed", details };
251
- const result = routeDeveloperOutcome(outcome, INFRA_AT_LIMIT);
299
+ const result = routeDeveloperOutcome(outcome, CI_AT_LIMIT);
252
300
  assert.equal(result.next, "human_action");
253
301
  assert.equal(result.actionType, "policy_escalation");
302
+ assert.match(result.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
254
303
  assert.match(result.reason, /at abc123: build/);
255
- assert.match(result.reason, /infra-attempt limit reached/);
304
+ assert.doesNotMatch(result.reason, /infra-attempt limit reached/);
305
+ });
306
+ test("NOT-313: the budgets are independent — CI failures ignore the infra limit and infra failures ignore the CI limit", () => {
307
+ // An exhausted infra budget does not stop CI repair while CI budget remains.
308
+ const ciRetry = routeDeveloperOutcome({ kind: "checks_failed" }, INFRA_AT_LIMIT);
309
+ assert.equal(ciRetry.next, "retry_developer");
310
+ // An exhausted CI budget does not stop infra retries while infra budget remains.
311
+ for (const outcome of [{ kind: "session_failed" }, { kind: "timed_out" }, { kind: "no_pr" }]) {
312
+ const infraRetry = routeDeveloperOutcome(outcome, { ...CI_AT_LIMIT, infraAttempts: 1 });
313
+ assert.equal(infraRetry.next, "retry_developer", outcome.kind);
314
+ assert.ok(!("budget" in infraRetry) || infraRetry.budget === undefined);
315
+ }
256
316
  });
257
317
  test("an infra-class developer failure never spends the review-round budget, even at the review-round limit", () => {
258
318
  const outcome = { kind: "session_failed" };
@@ -448,10 +508,11 @@ test("NOT-147: tip with commits ahead still auto-retries under existing infra bu
448
508
  }
449
509
  });
450
510
  test("NOT-147: no_pr / checks_failed are unaffected by the empty-tip gate (timeout/crash only)", () => {
451
- // Even with infraAttempts already spent and zero progress implied, non-crash infra
452
- // failures keep the pre-NOT-147 budget-only policy.
511
+ // Even with attempts already spent and zero progress implied, non-crash failures
512
+ // keep the pre-NOT-147 budget-only policy — no_pr on the infra budget,
513
+ // checks_failed on its own CI budget (NOT-313).
453
514
  assert.equal(routeDeveloperOutcome({ kind: "no_pr" }, INFRA_ATTEMPTS_LEFT).next, "retry_developer");
454
- assert.equal(routeDeveloperOutcome({ kind: "checks_failed" }, INFRA_ATTEMPTS_LEFT).next, "retry_developer");
515
+ assert.equal(routeDeveloperOutcome({ kind: "checks_failed" }, CI_BUDGET_LEFT).next, "retry_developer");
455
516
  });
456
517
  test("NOT-147: noProgressInfraAttempts is tunable (N=1 escalates on first empty-tip crash)", () => {
457
518
  const limits = { ...REVIEW_ROUNDS_LEFT, noProgressInfraAttempts: 1 };
@@ -5,6 +5,36 @@ export function developerSessionTimeoutMs() {
5
5
  export function reviewerSessionTimeoutMs() {
6
6
  return num("REVIEWER_TIMEOUT_MS", 30 * 60_000);
7
7
  }
8
+ /**
9
+ * NOT-307: silent-child bound for the Muse developer lane only (other runtimes do
10
+ * not read this). Undefined means the watchdog is disabled. The watchdog ships
11
+ * ENABLED with a 30-minute default: 20 min risks false kills against the
12
+ * reported ~39.8 min silent gap in completed-session history; 45 min saves only
13
+ * 15 of the 60-min wall-clock cap; disabled does not address the observed
14
+ * 1-hour stalls. The ~39.8 min figure could not be re-verified — historical raw
15
+ * logs carry replayed `recorded_at`, not arrival times, so it may overstate
16
+ * true stdout/session.jsonl silence. The value is env-tunable, and the new
17
+ * per-event `ts`/`durationMs` plus `metadata_json` stall fields will provide
18
+ * the real gap distribution to recalibrate; a false kill is bounded by timeout
19
+ * salvage + retry from the branch. `0` disables explicitly. A non-numeric or
20
+ * negative value falls back to the default with a logged warning rather than
21
+ * silently disabling the guard (or killing healthy sessions on a typo like
22
+ * `-1`).
23
+ */
24
+ export const DEFAULT_MUSE_IDLE_TIMEOUT_MS = 30 * 60_000;
25
+ export function museIdleTimeoutMs() {
26
+ const raw = process.env.MUSE_IDLE_TIMEOUT_MS;
27
+ if (raw === undefined || raw.trim() === "")
28
+ return DEFAULT_MUSE_IDLE_TIMEOUT_MS;
29
+ const parsed = Number(raw);
30
+ if (Number.isFinite(parsed) && parsed === 0)
31
+ return undefined;
32
+ if (!Number.isFinite(parsed) || parsed < 0) {
33
+ console.warn(`[dealer] ignoring invalid MUSE_IDLE_TIMEOUT_MS=${JSON.stringify(raw)} — using default ${DEFAULT_MUSE_IDLE_TIMEOUT_MS}`);
34
+ return DEFAULT_MUSE_IDLE_TIMEOUT_MS;
35
+ }
36
+ return parsed;
37
+ }
8
38
  export function sessionTimeoutMsFor(role) {
9
39
  return role === "reviewer" ? reviewerSessionTimeoutMs() : developerSessionTimeoutMs();
10
40
  }
@@ -15,6 +15,7 @@ import { deferWorkItem, getWorkItem } from "../repository/work-items.js";
15
15
  import { getWorkerSession } from "../repository/worker-sessions.js";
16
16
  import { deckOutageBackoffMs, deckOutageProlongedAfterMs } from "./deck-outage-config.js";
17
17
  import { usageCapDeferralCeilingMs } from "./usage-cap-config.js";
18
+ import { checksWaitCeilingExceeded } from "./checks-wait-config.js";
18
19
  import { workerSessionPayload } from "./session-progress.js";
19
20
  const roleFor = {
20
21
  developer: "developer",
@@ -255,6 +256,66 @@ export function deferLeasedWorkItemForAgentUnhealthy(item, leaseToken, unhealthy
255
256
  });
256
257
  })();
257
258
  }
259
+ export function checksWaitDeferralStartedAt(payload) {
260
+ const v = payload.checksWaitStartedAt;
261
+ return typeof v === "string" && v ? v : null;
262
+ }
263
+ /** How many times this item has already waited on CI — drives the backoff curve. */
264
+ export function checksWaitDeferralCount(payload) {
265
+ const v = payload.checksWaitDeferrals;
266
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
267
+ }
268
+ /**
269
+ * NOT-311: CI was still pending when the poll timed out, so nothing about the work
270
+ * was proven or disproven. The item waits on the deck-outage backoff and re-polls —
271
+ * no infra attempt, and the claim-time attempt_count bump is reverted, exactly like a
272
+ * deck outage. Unlike an outage this wait has a ceiling: past it the deferral
273
+ * reports `escalated` so the caller raises a human action naming the stuck PR/SHA.
274
+ *
275
+ * The re-queued payload carries `publishOnly` + `branch` so the next run takes the
276
+ * no-agent publish path (push-if-behind, PR verify, re-poll) instead of spawning a
277
+ * fresh developer session to redo committed work.
278
+ */
279
+ export function deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance, nowMs = Date.now()) {
280
+ return getDb().transaction(() => {
281
+ const live = liveLeasedItem(item.id, leaseToken);
282
+ if (!live)
283
+ return { deferred: false, escalated: false, reason: "lease_lost" };
284
+ const payload = parsePayload(live.payloadJson);
285
+ const firstDeferredAt = checksWaitDeferralStartedAt(payload) ?? pending.waitStartedAt ?? new Date(nowMs).toISOString();
286
+ if (checksWaitCeilingExceeded(firstDeferredAt, nowMs)) {
287
+ return { deferred: false, escalated: true };
288
+ }
289
+ const priorDeferrals = checksWaitDeferralCount(payload);
290
+ const until = new Date(nowMs + deckOutageBackoffMs(priorDeferrals)).toISOString();
291
+ return applyDeferral(live, leaseToken, issue, instance, {
292
+ until,
293
+ reason: pending.reason,
294
+ outcome: "checks_pending",
295
+ error: {
296
+ kind: "checks_pending",
297
+ until,
298
+ reason: pending.reason,
299
+ branch: pending.branch,
300
+ prNumber: pending.prNumber,
301
+ headSha: pending.headSha,
302
+ },
303
+ payloadJson: JSON.stringify({
304
+ ...payload,
305
+ publishOnly: true,
306
+ branch: pending.branch,
307
+ checksWaitStartedAt: firstDeferredAt,
308
+ checksWaitDeferrals: priorDeferrals + 1,
309
+ }),
310
+ intent: (_role, untilLabel) => `${pending.reason} (retrying ${untilLabel})`,
311
+ });
312
+ })();
313
+ }
314
+ /** NOT-311: ceiling escalation reason — names the PR and the head SHA CI never proved. */
315
+ export function formatChecksWaitEscalationReason(pending, firstDeferredAt) {
316
+ return (`CI did not complete on ${pending.headSha} (PR #${pending.prNumber}, branch ${pending.branch}) — ` +
317
+ `waiting since ${firstDeferredAt}. ${pending.reason}`);
318
+ }
258
319
  export function formatCapEscalationReason(cap, firstDeferredAt) {
259
320
  const hours = Math.round(usageCapDeferralCeilingMs() / 3_600_000);
260
321
  return `${cap.reason} Work deferred for over ${hours}h (since ${firstDeferredAt}).`;
@@ -32,8 +32,8 @@ beforeEach(() => {
32
32
  DELETE FROM human_actions;
33
33
  DELETE FROM workflow_events;
34
34
  DELETE FROM findings;
35
- DELETE FROM worker_sessions;
36
35
  DELETE FROM artifacts;
36
+ DELETE FROM worker_sessions;
37
37
  DELETE FROM usage_events;
38
38
  DELETE FROM workflow_instances;
39
39
  DELETE FROM issues;
@@ -83,6 +83,15 @@ function safeCompleteSession(sessionId, status, error) {
83
83
  catch (err) {
84
84
  console.error("[coordinator] completeSession", sessionId, err);
85
85
  }
86
+ // NOT-305: best-effort actual-use receipt the moment the session goes terminal — a
87
+ // later issue-completion trigger re-attempts anything still missing, so this never
88
+ // throws and never changes the session outcome or the issue state.
89
+ try {
90
+ void import("./playbook-feedback.js").then(({ collectPlaybookUseReceipt }) => collectPlaybookUseReceipt(sessionId).catch(() => { }));
91
+ }
92
+ catch (err) {
93
+ console.error("[coordinator] playbook receipt", sessionId, err);
94
+ }
86
95
  }
87
96
  /**
88
97
  * A failed effect (handler threw, or lost its lease mid-run). One transaction, fenced on
@@ -269,10 +278,6 @@ async function processWorkItem(claimed) {
269
278
  safeCompleteSession(session.id, "cancelled", { reason: result.reason });
270
279
  return;
271
280
  }
272
- if (result.triggerReflect) {
273
- const { triggerIssueReflect } = await import("./reflect-trigger.js");
274
- void triggerIssueReflect(claimed.issueId).catch(() => { });
275
- }
276
281
  // NOT-136: a deck-unavailable session never spawned anything — `cancelled`, not `failed`
277
282
  // (which would read as a worker crash) and not `done` (which would claim it ran).
278
283
  // NOT-197: a base-fetch failure likewise never spawned (and created no branch).
@@ -298,7 +303,16 @@ async function processWorkItem(claimed) {
298
303
  }),
299
304
  }
300
305
  : undefined;
306
+ // NOT-305: the session row goes terminal BEFORE the issue-level reflect trigger
307
+ // below. triggerIssueReflect collects receipts for every terminal session, and
308
+ // completeSession writes the terminal status synchronously — firing reflect first
309
+ // would exclude this just-finished session from the signal's worker sessions and
310
+ // playbook evidence.
301
311
  safeCompleteSession(session.id, sessionStatus, errorPayload);
312
+ if (result.triggerReflect) {
313
+ const { triggerIssueReflect } = await import("./reflect-trigger.js");
314
+ void triggerIssueReflect(claimed.issueId).catch(() => { });
315
+ }
302
316
  }
303
317
  /**
304
318
  * Claims and starts as many work items as free concurrency slots allow, then returns the
@@ -0,0 +1,70 @@
1
+ // NOT-313: the CI-repair budget columns are added by separate `ALTER TABLE`
2
+ // statements, each guarded on its own presence (same lesson as the
3
+ // worker_sessions liveness columns in worker-session-pid-migration.test.ts): a
4
+ // kill between the two ALTERs must still repair on the next migrate().
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-ci-migrate-"));
11
+ const { migrate, getDb } = await import("./index.js");
12
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
13
+ const { createIssue, getIssue } = await import("../repository/issues.js");
14
+ const CI_COLS = ["ci_attempts", "max_ci_attempts"]; // sorted: ciCols() sorts
15
+ const ciCols = () => getDb().prepare("PRAGMA table_info(issues)").all()
16
+ .map((c) => c.name)
17
+ .filter((c) => CI_COLS.includes(c))
18
+ .sort();
19
+ function newIssueId() {
20
+ return createIssue({
21
+ title: "T",
22
+ repo: "/r",
23
+ baseBranch: "main",
24
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
25
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
26
+ maxReviewRounds: 3,
27
+ maxInfraAttempts: 3,
28
+ source: "manual",
29
+ }).id;
30
+ }
31
+ test("migrate() adds both CI-budget columns to a DB that predates them, keeping every row", () => {
32
+ migrate();
33
+ const before = newIssueId();
34
+ const rowCount = getDb().prepare("SELECT COUNT(*) AS c FROM issues").get().c;
35
+ // A DB created before NOT-313.
36
+ getDb().exec("ALTER TABLE issues DROP COLUMN max_ci_attempts");
37
+ getDb().exec("ALTER TABLE issues DROP COLUMN ci_attempts");
38
+ assert.deepStrictEqual(ciCols(), []);
39
+ migrate();
40
+ assert.deepStrictEqual(ciCols(), CI_COLS);
41
+ // All rows survive with the defaults.
42
+ assert.equal(getDb().prepare("SELECT COUNT(*) AS c FROM issues").get().c, rowCount);
43
+ const reread = getDb()
44
+ .prepare("SELECT max_ci_attempts, ci_attempts FROM issues WHERE id = ?")
45
+ .get(before);
46
+ assert.equal(reread.max_ci_attempts, 3);
47
+ assert.equal(reread.ci_attempts, 0);
48
+ // The repository maps the backfilled columns, and new issues open with a full budget.
49
+ const issue = getIssue(before);
50
+ assert.equal(issue.maxCiAttempts, 3);
51
+ assert.equal(issue.ciAttempts, 0);
52
+ const fresh = getIssue(newIssueId());
53
+ assert.equal(fresh.maxCiAttempts, 3);
54
+ assert.equal(fresh.ciAttempts, 0);
55
+ });
56
+ test("migrate() repairs a half-applied CI upgrade instead of skipping it forever", () => {
57
+ migrate();
58
+ // Exactly the partial state a crash between the two ALTERs leaves behind.
59
+ getDb().exec("ALTER TABLE issues DROP COLUMN ci_attempts");
60
+ assert.deepStrictEqual(ciCols(), ["max_ci_attempts"]);
61
+ migrate();
62
+ assert.deepStrictEqual(ciCols(), CI_COLS, "the missing column is added");
63
+ assert.doesNotThrow(() => newIssueId(), "issue inserts name the restored column");
64
+ });
65
+ test("migrate() is idempotent once both CI columns are present", () => {
66
+ migrate();
67
+ migrate();
68
+ assert.deepStrictEqual(ciCols(), CI_COLS);
69
+ assert.doesNotThrow(() => newIssueId());
70
+ });
@@ -131,6 +131,11 @@ export function migrate() {
131
131
  if (!workerSessionCols.some((c) => c.name === "process_started_at")) {
132
132
  db.exec("ALTER TABLE worker_sessions ADD COLUMN process_started_at TEXT");
133
133
  }
134
+ // NOT-305: opaque Deck correlation UUID per session. Pre-feature rows stay NULL,
135
+ // which readers treat as "no correlation" (receipt records a skip, never a guess).
136
+ if (!workerSessionCols.some((c) => c.name === "deck_correlation_id")) {
137
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN deck_correlation_id TEXT");
138
+ }
134
139
  // NOT-181: the model a session actually ran (Muse's server-confirmed one). Existing rows stay
135
140
  // NULL, which every reader treats as "not recorded".
136
141
  const usageEventCols = db.prepare("PRAGMA table_info(usage_events)").all();
@@ -246,6 +251,19 @@ export function migrate() {
246
251
  db.exec("ALTER TABLE issues ADD COLUMN max_infra_attempts INTEGER NOT NULL DEFAULT 3");
247
252
  db.exec("ALTER TABLE issues ADD COLUMN infra_attempts INTEGER NOT NULL DEFAULT 0");
248
253
  }
254
+ // NOT-313: CI-repair budget, separate from the infra-attempt budget above — a
255
+ // failing CI check is the expected feedback loop, not a crash/timeout/adapter
256
+ // failure, so `checks_failed` spends `ci_attempts` (bounded by
257
+ // `max_ci_attempts`) and never touches `infra_attempts`. Each column is guarded
258
+ // on its OWN presence (see the worker_sessions liveness comment above): the two
259
+ // ALTERs commit separately, so a kill between them must still repair on retry.
260
+ const issueColsForCi = db.prepare("PRAGMA table_info(issues)").all();
261
+ if (!issueColsForCi.some((c) => c.name === "max_ci_attempts")) {
262
+ db.exec("ALTER TABLE issues ADD COLUMN max_ci_attempts INTEGER NOT NULL DEFAULT 3");
263
+ }
264
+ if (!issueColsForCi.some((c) => c.name === "ci_attempts")) {
265
+ db.exec("ALTER TABLE issues ADD COLUMN ci_attempts INTEGER NOT NULL DEFAULT 0");
266
+ }
249
267
  // NOT-102: per-issue auto-merge after reviewer approve (kick UI defaults on; existing
250
268
  // rows stay off so in-flight workflows keep the human final_review path).
251
269
  const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
@@ -114,6 +114,8 @@ CREATE TABLE IF NOT EXISTS issues (
114
114
  current_round INTEGER NOT NULL DEFAULT 1,
115
115
  max_infra_attempts INTEGER NOT NULL DEFAULT 3,
116
116
  infra_attempts INTEGER NOT NULL DEFAULT 0,
117
+ max_ci_attempts INTEGER NOT NULL DEFAULT 3,
118
+ ci_attempts INTEGER NOT NULL DEFAULT 0,
117
119
  branch TEXT,
118
120
  base_sha TEXT,
119
121
  head_sha TEXT,
@@ -155,6 +157,9 @@ CREATE TABLE IF NOT EXISTS worker_sessions (
155
157
  process_pid INTEGER,
156
158
  process_owner TEXT,
157
159
  process_started_at TEXT,
160
+ -- NOT-305: opaque Agent Deck correlation UUID, one per session, generated before
161
+ -- spawn and passed as observability metadata in every runtime's Deck launch config.
162
+ deck_correlation_id TEXT,
158
163
  created_at TEXT NOT NULL,
159
164
  started_at TEXT,
160
165
  heartbeat_at TEXT,
@@ -67,6 +67,7 @@ test("the contract defines every phase, taxonomy code, and quality label", () =>
67
67
  "provider_capacity_rate_limit",
68
68
  "agent_cli_crash",
69
69
  "tool_test_timeout",
70
+ "muse_no_progress",
70
71
  "coordinator_crash",
71
72
  "validation_failure",
72
73
  "publish_git_failure",
@@ -23,10 +23,10 @@ export function listArtifactsForIssue(issueId, opts) {
23
23
  const limit = opts?.limit ?? 50;
24
24
  const rows = opts?.before
25
25
  ? getDb()
26
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?")
26
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND created_at < ? ORDER BY created_at DESC, rowid DESC LIMIT ?")
27
27
  .all(issueId, opts.before, limit)
28
28
  : getDb()
29
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? ORDER BY created_at DESC LIMIT ?")
29
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?")
30
30
  .all(issueId, limit);
31
31
  return rows.map(rowToArtifact);
32
32
  }
@@ -36,7 +36,7 @@ export function listArtifactsForIssue(issueId, opts) {
36
36
  * scan) can't have an old row fall outside the window on an artifact-heavy issue. */
37
37
  export function listArtifactsForIssueByKind(issueId, kind) {
38
38
  const rows = getDb()
39
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND kind = ? ORDER BY created_at DESC")
39
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND kind = ? ORDER BY created_at DESC, rowid DESC")
40
40
  .all(issueId, kind);
41
41
  return rows.map(rowToArtifact);
42
42
  }
@@ -119,6 +119,20 @@ export function findOpenHumanActionByRequestId(issueId, actionType, requestId) {
119
119
  .get(issueId, actionType, requestId);
120
120
  return row ? rowToAction(row) : null;
121
121
  }
122
+ /**
123
+ * NOT-308: version-scoped Muse capability escalations live on whichever issue first
124
+ * observed the verdict, but later admissions for *other* issues must find the same
125
+ * action instead of raising a duplicate — so the lookup is by (type, request_id)
126
+ * across issues, not per issue. Returns open actions first so callers can skip
127
+ * creation; resolved ones are included so callers can distinguish "already handled".
128
+ */
129
+ export function listHumanActionsByRequestId(actionType, requestId) {
130
+ const rows = getDb()
131
+ .prepare(`SELECT * FROM human_actions WHERE action_type = ? AND request_id = ?
132
+ ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, requested_at ASC`)
133
+ .all(actionType, requestId);
134
+ return rows.map(rowToAction);
135
+ }
122
136
  export function listHumanActionsForIssue(issueId) {
123
137
  const rows = getDb()
124
138
  .prepare("SELECT * FROM human_actions WHERE issue_id = ? ORDER BY requested_at ASC")
@@ -1,4 +1,4 @@
1
- import { canTransitionIssue, CreateIssueInput, looksLikeLocalRepoPath, parseGitHubRepoInput, TERMINAL_ISSUE_STATUSES, } from "@agent-dealer/shared";
1
+ import { canTransitionIssue, compileExecutionContract, CreateIssueInput, DEFAULT_MAX_CI_ATTEMPTS, looksLikeLocalRepoPath, parseGitHubRepoInput, resolveIssueContractFields, tryCompileContract, TERMINAL_ISSUE_STATUSES, } from "@agent-dealer/shared";
2
2
  import { v4 as uuid } from "uuid";
3
3
  import { getDb } from "../db/index.js";
4
4
  function rowToIssue(row) {
@@ -11,6 +11,11 @@ function rowToIssue(row) {
11
11
  title: row.title,
12
12
  description: row.description,
13
13
  acceptanceCriteria: row.acceptance_criteria,
14
+ // NOT-306: the contract is derived from the stored description on every
15
+ // read — the description stays the untouched source of truth, so no
16
+ // migration or second authoring surface exists. A description that fails
17
+ // validation (written before validation existed) reads as a legacy null.
18
+ executionContract: tryCompileContract(row.description),
14
19
  repo: row.repo,
15
20
  baseBranch: row.base_branch,
16
21
  status: row.status,
@@ -22,6 +27,8 @@ function rowToIssue(row) {
22
27
  currentRound: row.current_round,
23
28
  maxInfraAttempts: row.max_infra_attempts,
24
29
  infraAttempts: row.infra_attempts,
30
+ maxCiAttempts: row.max_ci_attempts ?? DEFAULT_MAX_CI_ATTEMPTS,
31
+ ciAttempts: row.ci_attempts ?? 0,
25
32
  branch: row.branch,
26
33
  baseSha: row.base_sha,
27
34
  headSha: row.head_sha,
@@ -49,6 +56,12 @@ export function createIssue(raw) {
49
56
  const { repo: repoRaw, ...rest } = raw;
50
57
  const input = CreateIssueInput.omit({ repo: true }).parse(rest);
51
58
  const repo = normalizeStoredIssueRepo(repoRaw);
59
+ // NOT-306: validate the ticket's execution contract here so every creation
60
+ // path (web, API, CLI-via-API, Linear import) shares one compiler — an
61
+ // ambiguous/malformed contract throws instead of silently dropping fields.
62
+ // A missing explicit acceptanceCriteria is filled from the description; an
63
+ // explicit value always wins and the description is stored untouched.
64
+ const resolved = resolveIssueContractFields(input.description, input.acceptanceCriteria);
52
65
  const db = getDb();
53
66
  const now = new Date().toISOString();
54
67
  const id = uuid();
@@ -60,7 +73,7 @@ export function createIssue(raw) {
60
73
  external_url: input.externalUrl ?? null,
61
74
  title: input.title,
62
75
  description: input.description ?? null,
63
- acceptance_criteria: input.acceptanceCriteria ?? null,
76
+ acceptance_criteria: resolved.acceptanceCriteria,
64
77
  repo,
65
78
  base_branch: input.baseBranch,
66
79
  status: "ready",
@@ -72,6 +85,10 @@ export function createIssue(raw) {
72
85
  current_round: 1,
73
86
  max_infra_attempts: input.maxInfraAttempts,
74
87
  infra_attempts: 0,
88
+ // NOT-313: no per-ticket config (stays out of CreateIssueInput) — every new
89
+ // issue opens with the full default CI-repair budget, unspent.
90
+ max_ci_attempts: DEFAULT_MAX_CI_ATTEMPTS,
91
+ ci_attempts: 0,
75
92
  branch: null,
76
93
  base_sha: null,
77
94
  head_sha: null,
@@ -86,13 +103,13 @@ export function createIssue(raw) {
86
103
  id, source, external_id, external_label, external_url, title, description,
87
104
  acceptance_criteria, repo, base_branch, status, current_owner, current_intent,
88
105
  developer_agent_id, reviewer_agent_id, max_review_rounds, current_round,
89
- max_infra_attempts, infra_attempts,
106
+ max_infra_attempts, infra_attempts, max_ci_attempts, ci_attempts,
90
107
  branch, base_sha, head_sha, pr_number, pr_url, auto_merge, created_at, updated_at
91
108
  ) VALUES (
92
109
  @id, @source, @external_id, @external_label, @external_url, @title, @description,
93
110
  @acceptance_criteria, @repo, @base_branch, @status, @current_owner, @current_intent,
94
111
  @developer_agent_id, @reviewer_agent_id, @max_review_rounds, @current_round,
95
- @max_infra_attempts, @infra_attempts,
112
+ @max_infra_attempts, @infra_attempts, @max_ci_attempts, @ci_attempts,
96
113
  @branch, @base_sha, @head_sha, @pr_number, @pr_url, @auto_merge, @created_at, @updated_at
97
114
  )
98
115
  `).run(row);
@@ -263,6 +280,20 @@ export function updateIssue(id, patch) {
263
280
  const current = getIssue(id);
264
281
  if (!current)
265
282
  throw new Error(`Issue not found: ${id}`);
283
+ // NOT-306: an edited description recompiles through the same compiler as
284
+ // creation — an ambiguous/malformed contract throws instead of landing
285
+ // half-parsed. A missing acceptanceCriteria is backfilled from the new
286
+ // description only when the issue has none; an explicit value always wins.
287
+ // Validation runs only when the patch touches the description, so unrelated
288
+ // updates (e.g. a base-branch-only sync) never fail on a stored description
289
+ // that predates validation.
290
+ const nextDescription = patch.description !== undefined ? patch.description : current.description;
291
+ const compiled = patch.description !== undefined ? compileExecutionContract(nextDescription) : null;
292
+ const nextAcceptanceCriteria = patch.acceptanceCriteria !== undefined
293
+ ? patch.acceptanceCriteria
294
+ : current.acceptanceCriteria?.trim()
295
+ ? current.acceptanceCriteria
296
+ : (compiled?.acceptanceCriteria ?? current.acceptanceCriteria);
266
297
  const now = new Date().toISOString();
267
298
  getDb()
268
299
  .prepare(`
@@ -283,8 +314,8 @@ export function updateIssue(id, patch) {
283
314
  .run({
284
315
  id,
285
316
  title: patch.title ?? current.title,
286
- description: patch.description !== undefined ? patch.description : current.description,
287
- acceptance_criteria: patch.acceptanceCriteria !== undefined ? patch.acceptanceCriteria : current.acceptanceCriteria,
317
+ description: nextDescription,
318
+ acceptance_criteria: nextAcceptanceCriteria,
288
319
  repo: patch.repo !== undefined ? normalizeStoredIssueRepo(patch.repo) : current.repo,
289
320
  base_branch: patch.baseBranch ?? current.baseBranch,
290
321
  developer_agent_id: patch.developerAgentId ?? current.developerAgentId,
@@ -349,6 +380,21 @@ export function incrementIssueInfraAttempts(id) {
349
380
  throw new Error(`Issue vanished: ${id}`);
350
381
  return updated;
351
382
  }
383
+ /** NOT-313: spends one CI-repair attempt — a `checks_failed` being retried
384
+ * automatically. Never touches `infra_attempts`; see incrementIssueInfraAttempts. */
385
+ export function incrementIssueCiAttempts(id) {
386
+ const current = getIssue(id);
387
+ if (!current)
388
+ throw new Error(`Issue not found: ${id}`);
389
+ const now = new Date().toISOString();
390
+ getDb()
391
+ .prepare("UPDATE issues SET ci_attempts = ci_attempts + 1, updated_at = ? WHERE id = ?")
392
+ .run(now, id);
393
+ const updated = getIssue(id);
394
+ if (!updated)
395
+ throw new Error(`Issue vanished: ${id}`);
396
+ return updated;
397
+ }
352
398
  /** A human resuming past a `policy_escalation` gets a fresh infra-attempt budget. */
353
399
  export function resetIssueInfraAttempts(id) {
354
400
  const current = getIssue(id);
@@ -363,6 +409,21 @@ export function resetIssueInfraAttempts(id) {
363
409
  throw new Error(`Issue vanished: ${id}`);
364
410
  return updated;
365
411
  }
412
+ /** NOT-313: a human resuming past a CI-checks `policy_escalation` gets a fresh
413
+ * CI-repair budget alongside the infra one (commands.ts resets both together). */
414
+ export function resetIssueCiAttempts(id) {
415
+ const current = getIssue(id);
416
+ if (!current)
417
+ throw new Error(`Issue not found: ${id}`);
418
+ const now = new Date().toISOString();
419
+ getDb()
420
+ .prepare("UPDATE issues SET ci_attempts = 0, updated_at = ? WHERE id = ?")
421
+ .run(now, id);
422
+ const updated = getIssue(id);
423
+ if (!updated)
424
+ throw new Error(`Issue vanished: ${id}`);
425
+ return updated;
426
+ }
366
427
  /** `attempts_exhausted:retry` grants one more review round instead of instantly
367
428
  * re-exhausting: bumps current_round AND max_review_rounds together. */
368
429
  export function grantReviewRetry(id) {
@@ -97,6 +97,13 @@ export function getWorkItem(id) {
97
97
  const row = getDb().prepare("SELECT * FROM work_items WHERE id = ?").get(id);
98
98
  return row ? rowToWorkItem(row) : null;
99
99
  }
100
+ /** Finds the row a retried enqueue would dedupe against (null when the key is unused). */
101
+ export function getWorkItemByIdempotencyKey(idempotencyKey) {
102
+ const row = getDb()
103
+ .prepare("SELECT * FROM work_items WHERE idempotency_key = ?")
104
+ .get(idempotencyKey);
105
+ return row ? rowToWorkItem(row) : null;
106
+ }
100
107
  export function listWorkItemsForIssue(issueId) {
101
108
  const rows = getDb()
102
109
  .prepare("SELECT * FROM work_items WHERE issue_id = ? ORDER BY created_at ASC")