agent-dealer 1.2.8 → 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 (51) hide show
  1. package/bundle/server/dist/adapters/github.js +80 -4
  2. package/bundle/server/dist/adapters/github.test.js +83 -1
  3. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  4. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +205 -1
  6. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  7. package/bundle/server/dist/coordinator/commands.js +194 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +145 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  11. package/bundle/server/dist/coordinator/human-resolution.js +19 -0
  12. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  13. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  14. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  15. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  16. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  17. package/bundle/server/dist/coordinator/projection.js +10 -4
  18. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  19. package/bundle/server/dist/coordinator/prompts.js +134 -1
  20. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  21. package/bundle/server/dist/coordinator/recovery.js +57 -3
  22. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  23. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  24. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  25. package/bundle/server/dist/coordinator/routing.js +45 -6
  26. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  27. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  28. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  29. package/bundle/server/dist/db/index.js +13 -0
  30. package/bundle/server/dist/db/schema.sql +2 -0
  31. package/bundle/server/dist/repository/issues.js +39 -3
  32. package/bundle/server/dist/repository/work-items.js +7 -0
  33. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  34. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  35. package/bundle/server/package.json +2 -2
  36. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  37. package/bundle/server/static-ui/index.html +1 -1
  38. package/bundle/shared/dist/human-actions.d.ts +4 -4
  39. package/bundle/shared/dist/human-actions.js +8 -0
  40. package/bundle/shared/dist/issues.d.ts +30 -0
  41. package/bundle/shared/dist/issues.js +14 -0
  42. package/bundle/shared/dist/issues.test.js +2 -0
  43. package/bundle/shared/dist/workflow.d.ts +4 -4
  44. package/bundle/shared/dist/workflow.js +6 -0
  45. package/bundle/shared/dist/workflow.test.js +1 -0
  46. package/bundle/shared/package.json +1 -1
  47. package/dist/action.js +4 -1
  48. package/dist/action.test.js +42 -0
  49. package/dist/index.js +1 -1
  50. package/package.json +1 -1
  51. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -1,4 +1,5 @@
1
1
  // packages/server/src/coordinator/routing.ts
2
+ import { DEFAULT_MAX_CI_ATTEMPTS } from "@agent-dealer/shared";
2
3
  import { normalizeReviewerResult } from "./reviewer-result.js";
3
4
  /** Default N for NOT-147: escalate empty-tip timeout/crash after this many failures (one auto-retry). */
4
5
  export const DEFAULT_NO_PROGRESS_INFRA_ATTEMPTS = 2;
@@ -14,6 +15,13 @@ function roundsRemain(limits) {
14
15
  export function infraAttemptsRemain(limits) {
15
16
  return limits.infraAttempts < limits.maxInfraAttempts;
16
17
  }
18
+ /**
19
+ * NOT-313: the one definition of "is there CI-repair budget left", mirroring
20
+ * {@link infraAttemptsRemain}. Absent counters read as a fresh default budget.
21
+ */
22
+ export function ciAttemptsRemain(limits) {
23
+ return (limits.ciAttempts ?? 0) < (limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS);
24
+ }
17
25
  export function routeDeveloperOutcome(outcome, limits) {
18
26
  switch (outcome.kind) {
19
27
  case "clean_handoff":
@@ -103,7 +111,6 @@ export function routeDeveloperOutcome(outcome, limits) {
103
111
  : { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
104
112
  }
105
113
  case "no_pr":
106
- case "checks_failed":
107
114
  case "deck_failure": {
108
115
  // Unified infra-failure policy: the session/environment failed to produce a
109
116
  // reviewable result at all — bounded auto-retry on max_infra_attempts, decoupled
@@ -113,6 +120,30 @@ export function routeDeveloperOutcome(outcome, limits) {
113
120
  ? { next: "retry_developer", reason }
114
121
  : { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
115
122
  }
123
+ case "checks_failed": {
124
+ // NOT-313: a failing CI check is the expected feedback loop, not an
125
+ // infrastructure failure — bounded on max_ci_attempts, never the infra
126
+ // budget. The retry reason names the attempt number so the next session's
127
+ // prompt reads "CI repair attempt N of M"; the escalation names the same
128
+ // count plus the failing checks (via the NOT-252 enrichment), never the
129
+ // infra-attempt text. policy_escalation (not attempts_exhausted) so a human
130
+ // "resume" resets budgets without spending a review round.
131
+ const spent = limits.ciAttempts ?? 0;
132
+ const max = limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS;
133
+ const base = checksFailedDetails(outcome);
134
+ if (ciAttemptsRemain(limits)) {
135
+ return {
136
+ next: "retry_developer",
137
+ reason: `CI repair attempt ${spent + 1} of ${max}: ${base}`,
138
+ budget: "ci",
139
+ };
140
+ }
141
+ return {
142
+ next: "human_action",
143
+ actionType: "policy_escalation",
144
+ reason: `CI checks still failing after ${spent} repair attempts (limit ${max}). ${base}`,
145
+ };
146
+ }
116
147
  case "session_failed":
117
148
  case "timed_out": {
118
149
  // Same infra budget as above, plus NOT-147: empty tip after N timeout/crashes → human
@@ -140,6 +171,11 @@ export function routeDeveloperOutcome(outcome, limits) {
140
171
  // NOT-197: a hard-down network is not retryable on the infra-attempt timescale, and
141
172
  // nothing was spawned, so there is no attempt to charge. Wait for the network instead.
142
173
  return { next: "defer_work", reason: outcome.reason };
174
+ case "checks_pending":
175
+ // NOT-311: slow or not-yet-registered CI is not a developer failure — the
176
+ // session ended cleanly with a pushed branch and an open PR. Wait on backoff
177
+ // and re-poll; the deferral path (not this route) enforces the wait ceiling.
178
+ return { next: "defer_work", reason: outcome.reason };
143
179
  }
144
180
  }
145
181
  function infraFailureReason(outcome) {
@@ -150,17 +186,20 @@ function infraFailureReason(outcome) {
150
186
  return outcome.reason ?? "Developer session failed or crashed.";
151
187
  case "timed_out":
152
188
  return outcome.reason ?? "Developer session timed out.";
153
- case "checks_failed":
154
- // NOT-252: prefer the sanitized enrichment the effect threaded into `details`
155
- // (failing check names + bounded untrusted excerpt at the verified head SHA).
156
- // Blank/absent details keep today's exact generic reason.
157
- return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
158
189
  case "adapter_failure":
159
190
  return `Git/GitHub verification failed: ${outcome.reason}`;
160
191
  case "deck_failure":
161
192
  return `Agent Deck ${outcome.reason}`;
162
193
  }
163
194
  }
195
+ /**
196
+ * NOT-252: prefer the sanitized enrichment the effect threaded into `details`
197
+ * (failing check names + bounded untrusted excerpt at the verified head SHA).
198
+ * Blank/absent details keep the exact generic reason.
199
+ */
200
+ function checksFailedDetails(outcome) {
201
+ return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
202
+ }
164
203
  /**
165
204
  * NOT-147: when a timeout/crash left the branch with zero commits ahead of base, stop
166
205
  * burning full agent spawns after N failures (default 2 = one auto-retry). Only trips when
@@ -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",
@@ -230,43 +252,67 @@ test("NOT-307: an idle-watchdog timed_out routes exactly like a wall-clock timeo
230
252
  assert.deepStrictEqual(routeDeveloperOutcome({ kind: "timed_out" }, INFRA_AT_LIMIT).next, "human_action");
231
253
  assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_AT_LIMIT).next, "human_action");
232
254
  });
233
- test("checks_failed with infra attempts remaining retries (same bucket as session_failed/no_pr)", () => {
255
+ test("NOT-313: checks_failed retries on the CI budget with the attempt number, not the infra bucket", () => {
234
256
  const outcome = { kind: "checks_failed" };
235
- assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
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), {
236
264
  next: "retry_developer",
237
- reason: "Developer's PR checks failed.",
265
+ reason: "CI repair attempt 2 of 3: Developer's PR checks failed.",
266
+ budget: "ci",
238
267
  });
239
268
  });
240
269
  test("NOT-252: checks_failed with enrichment details retries with the enriched reason, not the generic one", () => {
241
270
  const details = "Developer's PR checks failed at abc123: build.\nFailed checks (PR #7 @ abc123):\n- build (CI · failure)";
242
271
  const outcome = { kind: "checks_failed", details };
243
- assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
272
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, CI_BUDGET_LEFT), {
244
273
  next: "retry_developer",
245
- reason: details,
274
+ reason: `CI repair attempt 2 of 3: ${details}`,
275
+ budget: "ci",
246
276
  });
247
277
  });
248
278
  test("NOT-252: checks_failed with blank details falls back to exactly the generic reason", () => {
249
279
  for (const outcome of [{ kind: "checks_failed", details: " " }, { kind: "checks_failed" }]) {
250
- assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
280
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, CI_BUDGET_LEFT), {
251
281
  next: "retry_developer",
252
- reason: "Developer's PR checks failed.",
282
+ reason: "CI repair attempt 2 of 3: Developer's PR checks failed.",
283
+ budget: "ci",
253
284
  });
254
285
  }
255
286
  });
256
- 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", () => {
257
288
  const outcome = { kind: "checks_failed" };
258
- const result = routeDeveloperOutcome(outcome, INFRA_AT_LIMIT);
289
+ const result = routeDeveloperOutcome(outcome, CI_AT_LIMIT);
259
290
  assert.equal(result.next, "human_action");
260
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/);
261
295
  });
262
- 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", () => {
263
297
  const details = "Developer's PR checks failed at abc123: build.";
264
298
  const outcome = { kind: "checks_failed", details };
265
- const result = routeDeveloperOutcome(outcome, INFRA_AT_LIMIT);
299
+ const result = routeDeveloperOutcome(outcome, CI_AT_LIMIT);
266
300
  assert.equal(result.next, "human_action");
267
301
  assert.equal(result.actionType, "policy_escalation");
302
+ assert.match(result.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
268
303
  assert.match(result.reason, /at abc123: build/);
269
- 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
+ }
270
316
  });
271
317
  test("an infra-class developer failure never spends the review-round budget, even at the review-round limit", () => {
272
318
  const outcome = { kind: "session_failed" };
@@ -462,10 +508,11 @@ test("NOT-147: tip with commits ahead still auto-retries under existing infra bu
462
508
  }
463
509
  });
464
510
  test("NOT-147: no_pr / checks_failed are unaffected by the empty-tip gate (timeout/crash only)", () => {
465
- // Even with infraAttempts already spent and zero progress implied, non-crash infra
466
- // 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).
467
514
  assert.equal(routeDeveloperOutcome({ kind: "no_pr" }, INFRA_ATTEMPTS_LEFT).next, "retry_developer");
468
- 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");
469
516
  });
470
517
  test("NOT-147: noProgressInfraAttempts is tunable (N=1 escalates on first empty-tip crash)", () => {
471
518
  const limits = { ...REVIEW_ROUNDS_LEFT, noProgressInfraAttempts: 1 };
@@ -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}).`;
@@ -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
+ });
@@ -251,6 +251,19 @@ export function migrate() {
251
251
  db.exec("ALTER TABLE issues ADD COLUMN max_infra_attempts INTEGER NOT NULL DEFAULT 3");
252
252
  db.exec("ALTER TABLE issues ADD COLUMN infra_attempts INTEGER NOT NULL DEFAULT 0");
253
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
+ }
254
267
  // NOT-102: per-issue auto-merge after reviewer approve (kick UI defaults on; existing
255
268
  // rows stay off so in-flight workflows keep the human final_review path).
256
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,
@@ -1,4 +1,4 @@
1
- import { canTransitionIssue, compileExecutionContract, CreateIssueInput, looksLikeLocalRepoPath, parseGitHubRepoInput, resolveIssueContractFields, tryCompileContract, 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) {
@@ -27,6 +27,8 @@ function rowToIssue(row) {
27
27
  currentRound: row.current_round,
28
28
  maxInfraAttempts: row.max_infra_attempts,
29
29
  infraAttempts: row.infra_attempts,
30
+ maxCiAttempts: row.max_ci_attempts ?? DEFAULT_MAX_CI_ATTEMPTS,
31
+ ciAttempts: row.ci_attempts ?? 0,
30
32
  branch: row.branch,
31
33
  baseSha: row.base_sha,
32
34
  headSha: row.head_sha,
@@ -83,6 +85,10 @@ export function createIssue(raw) {
83
85
  current_round: 1,
84
86
  max_infra_attempts: input.maxInfraAttempts,
85
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,
86
92
  branch: null,
87
93
  base_sha: null,
88
94
  head_sha: null,
@@ -97,13 +103,13 @@ export function createIssue(raw) {
97
103
  id, source, external_id, external_label, external_url, title, description,
98
104
  acceptance_criteria, repo, base_branch, status, current_owner, current_intent,
99
105
  developer_agent_id, reviewer_agent_id, max_review_rounds, current_round,
100
- max_infra_attempts, infra_attempts,
106
+ max_infra_attempts, infra_attempts, max_ci_attempts, ci_attempts,
101
107
  branch, base_sha, head_sha, pr_number, pr_url, auto_merge, created_at, updated_at
102
108
  ) VALUES (
103
109
  @id, @source, @external_id, @external_label, @external_url, @title, @description,
104
110
  @acceptance_criteria, @repo, @base_branch, @status, @current_owner, @current_intent,
105
111
  @developer_agent_id, @reviewer_agent_id, @max_review_rounds, @current_round,
106
- @max_infra_attempts, @infra_attempts,
112
+ @max_infra_attempts, @infra_attempts, @max_ci_attempts, @ci_attempts,
107
113
  @branch, @base_sha, @head_sha, @pr_number, @pr_url, @auto_merge, @created_at, @updated_at
108
114
  )
109
115
  `).run(row);
@@ -374,6 +380,21 @@ export function incrementIssueInfraAttempts(id) {
374
380
  throw new Error(`Issue vanished: ${id}`);
375
381
  return updated;
376
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
+ }
377
398
  /** A human resuming past a `policy_escalation` gets a fresh infra-attempt budget. */
378
399
  export function resetIssueInfraAttempts(id) {
379
400
  const current = getIssue(id);
@@ -388,6 +409,21 @@ export function resetIssueInfraAttempts(id) {
388
409
  throw new Error(`Issue vanished: ${id}`);
389
410
  return updated;
390
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
+ }
391
427
  /** `attempts_exhausted:retry` grants one more review round instead of instantly
392
428
  * re-exhausting: bumps current_round AND max_review_rounds together. */
393
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")
@@ -3,6 +3,31 @@
3
3
  // no per-attempt XDG dirs, no settings.json — those belong to the coordinator wiring (NOT-181).
4
4
  // Reviewer argv, resume and cancellation are out of scope.
5
5
  export const MUSE_COMMAND = "muse";
6
+ /**
7
+ * NOT-316: what the Muse developer sandbox cannot do, derived from the real
8
+ * launch flags in `buildMuseDeveloperInvocation` below. The developer prompt's
9
+ * "Environment limits" section is rendered from this constant (not hand-written
10
+ * prose), and `muse-code-args.test.ts` asserts the pairing — changing
11
+ * `--sandbox-network` without updating this constant fails a test.
12
+ *
13
+ * Live probe results behind these values: under `restricted` there is no DNS
14
+ * (npm registry and GitHub API unreachable) and loopback `listen()` fails with
15
+ * EPERM; under `proxy-only` network works through a local proxy but `listen()`
16
+ * still fails; under `enabled` both work. No headless browser or
17
+ * credentials/Keychain are provisioned into the developer session.
18
+ */
19
+ export const MUSE_SANDBOX_CAPABILITIES = {
20
+ /** Must equal the `--sandbox-network` value passed in the developer argv. */
21
+ sandboxNetwork: "restricted",
22
+ /** Effective network access under that sandbox mode. */
23
+ network: "none",
24
+ /** Whether loopback `listen()` works. */
25
+ loopbackListen: false,
26
+ /** Whether a browser is available for visual QA / screenshots. */
27
+ browser: false,
28
+ /** Whether credentials / Keychain are provisioned. */
29
+ credentialsKeychain: false,
30
+ };
6
31
  /** The launcher re-execs a newer binary hourly unless this is set, so the pinned version can drift. */
7
32
  export const MUSE_NO_AUTO_UPDATE_ENV = { MUSE_NO_AUTO_UPDATE: "1" };
8
33
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -0,0 +1,30 @@
1
+ // packages/server/src/runners/muse-code-args.test.ts
2
+ //
3
+ // NOT-316: `MUSE_SANDBOX_CAPABILITIES` is mechanically tied to the real launch
4
+ // flags — changing `--sandbox-network` without updating the constant fails a test.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { buildMuseDeveloperInvocation, MUSE_SANDBOX_CAPABILITIES, } from "./muse-code-args.js";
8
+ const baseOpts = {
9
+ model: "muse-spark-1.3-contributor",
10
+ maxModelSteps: 40,
11
+ sessionId: "0b6c3f0e-4c2a-4f0e-9d3b-2f6f1a9a7c11",
12
+ prompt: "Implement NOT-1",
13
+ };
14
+ function sandboxNetworkFlag() {
15
+ const args = buildMuseDeveloperInvocation(baseOpts).args;
16
+ const i = args.indexOf("--sandbox-network");
17
+ assert.notEqual(i, -1, "--sandbox-network must be present in the developer argv");
18
+ const value = args[i + 1];
19
+ assert.ok(value, "--sandbox-network must have a value");
20
+ return value;
21
+ }
22
+ test("NOT-316: MUSE_SANDBOX_CAPABILITIES.sandboxNetwork matches the --sandbox-network flag actually passed", () => {
23
+ assert.equal(sandboxNetworkFlag(), MUSE_SANDBOX_CAPABILITIES.sandboxNetwork);
24
+ });
25
+ test("NOT-316: sandbox capabilities state the restricted-sandbox limits (no network/listen/browser/credentials)", () => {
26
+ assert.equal(MUSE_SANDBOX_CAPABILITIES.network, "none");
27
+ assert.equal(MUSE_SANDBOX_CAPABILITIES.loopbackListen, false);
28
+ assert.equal(MUSE_SANDBOX_CAPABILITIES.browser, false);
29
+ assert.equal(MUSE_SANDBOX_CAPABILITIES.credentialsKeychain, false);
30
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.2.8",
25
+ "@agent-dealer/shared": "1.2.9",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",