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.
- package/bundle/server/dist/adapters/github.js +80 -4
- package/bundle/server/dist/adapters/github.test.js +83 -1
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/coordinator/auto-merge.js +205 -1
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +194 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +145 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- package/bundle/server/dist/coordinator/human-resolution.js +19 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts.js +134 -1
- package/bundle/server/dist/coordinator/prompts.test.js +148 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +13 -0
- package/bundle/server/dist/db/schema.sql +2 -0
- package/bundle/server/dist/repository/issues.js +39 -3
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +8 -0
- package/bundle/shared/dist/issues.d.ts +30 -0
- package/bundle/shared/dist/issues.js +14 -0
- package/bundle/shared/dist/issues.test.js +2 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +6 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- 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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
466
|
-
//
|
|
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" },
|
|
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.
|
|
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.
|
|
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",
|