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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +84 -5
- package/bundle/server/dist/adapters/github.test.js +83 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- 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/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +244 -4
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +284 -23
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +224 -4
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +36 -1
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -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 +693 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -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-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +211 -2
- package/bundle/server/dist/coordinator/prompts.test.js +231 -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/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +76 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +18 -0
- package/bundle/server/dist/db/schema.sql +5 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +67 -6
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- 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/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +17 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +176 -16
- package/bundle/shared/dist/issues.js +22 -0
- package/bundle/shared/dist/issues.test.js +3 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +16 -0
- package/bundle/shared/dist/workflow.test.js +3 -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-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("
|
|
220
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
452
|
-
//
|
|
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" },
|
|
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,
|
|
@@ -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:
|
|
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:
|
|
287
|
-
acceptance_criteria:
|
|
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")
|