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
|
@@ -27,14 +27,27 @@ import { classifyIssueRepo } from "../adapters/managed-repo.js";
|
|
|
27
27
|
import { getDb } from "../db/index.js";
|
|
28
28
|
import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
|
|
29
29
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
30
|
-
import { createHumanAction, findOpenHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
|
|
30
|
+
import { createHumanAction, findOpenHumanAction, findOpenHumanActionByRequestId, resolveHumanAction, } from "../repository/human-actions.js";
|
|
31
31
|
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
|
|
32
32
|
import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
|
|
33
|
+
import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
|
|
33
34
|
const run = promisify(execFile);
|
|
34
35
|
/** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
|
|
35
36
|
export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
36
37
|
/** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
|
|
37
38
|
export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
|
|
39
|
+
/**
|
|
40
|
+
* NOT-314: the issue intent while an `[operator]` gate holds the merge. Named
|
|
41
|
+
* verbatim by the ticket: the PR stays unmerged in `needs_human` under this intent
|
|
42
|
+
* until the human records a result or waives the criterion.
|
|
43
|
+
*/
|
|
44
|
+
export const OPERATOR_VERIFICATION_INTENT = "Operator verification required before merge";
|
|
45
|
+
/**
|
|
46
|
+
* NOT-314 fail-closed: the stable request id for the no-head escalation, so
|
|
47
|
+
* repeat finalizes dedupe onto the one open `policy_escalation` instead of
|
|
48
|
+
* stacking a new one per tick.
|
|
49
|
+
*/
|
|
50
|
+
export const OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID = "operator-verification-blocked:no-head";
|
|
38
51
|
const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
|
|
39
52
|
/**
|
|
40
53
|
* Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
|
|
@@ -184,6 +197,15 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
184
197
|
if (issue.prNumber == null) {
|
|
185
198
|
return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
|
|
186
199
|
}
|
|
200
|
+
// NOT-314 seam: this finalize is the single path from reviewer-approve to the
|
|
201
|
+
// `gh` merge — auto-merge-on routes here from applyCompletion, auto-merge-off
|
|
202
|
+
// arrives via final_review:merge / policy_escalation:retry_merge, and crash
|
|
203
|
+
// recovery re-enters through recoverStrandedAutoMerges. An `[operator]`
|
|
204
|
+
// criterion in the frozen snapshot blocks here (PR unmerged, needs_human)
|
|
205
|
+
// until a human records the result for the current head.
|
|
206
|
+
const operatorBlock = gateOperatorVerification(issue.id);
|
|
207
|
+
if (operatorBlock)
|
|
208
|
+
return operatorBlock;
|
|
187
209
|
const resolvedCwd = resolveAutoMergeCwd(issue.repo);
|
|
188
210
|
if (!resolvedCwd.ok) {
|
|
189
211
|
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
|
|
@@ -287,6 +309,188 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
287
309
|
};
|
|
288
310
|
})();
|
|
289
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* NOT-314: the `[operator]` merge gate. After reviewer approve and before the
|
|
314
|
+
* `gh` merge: when the frozen snapshot holds operator criteria and no result is
|
|
315
|
+
* recorded for the current head (a `verified` artifact or a
|
|
316
|
+
* `operator_verification.waived` event — both head-pinned), the PR stays
|
|
317
|
+
* unmerged and the issue parks in `needs_human` with an `operator_verification`
|
|
318
|
+
* action listing each criterion and its command.
|
|
319
|
+
*
|
|
320
|
+
* Returns null when the merge may proceed (no operator criteria, a recorded
|
|
321
|
+
* result for this head, or no workflow to gate). Idempotent: a second finalize
|
|
322
|
+
* for the same head reuses the open action instead of raising another.
|
|
323
|
+
*
|
|
324
|
+
* Fail-closed: when operator criteria exist, every non-proceed path returns a
|
|
325
|
+
* result (never null), so a missing head SHA or an unexpected status blocks the
|
|
326
|
+
* `gh` merge instead of letting it through.
|
|
327
|
+
*/
|
|
328
|
+
export function gateOperatorVerification(issueId) {
|
|
329
|
+
return getDb().transaction(() => {
|
|
330
|
+
const current = getIssue(issueId);
|
|
331
|
+
if (!current)
|
|
332
|
+
return null;
|
|
333
|
+
const active = getActiveWorkflowInstance(issueId);
|
|
334
|
+
if (!active)
|
|
335
|
+
return null;
|
|
336
|
+
const criteria = getOperatorCriteriaForIssue(current);
|
|
337
|
+
if (criteria.length === 0)
|
|
338
|
+
return null;
|
|
339
|
+
const head = current.headSha;
|
|
340
|
+
if (head && hasOperatorVerificationForHead(issueId, head))
|
|
341
|
+
return null;
|
|
342
|
+
if (current.status === "done")
|
|
343
|
+
return null;
|
|
344
|
+
// Only the auto-merge park (or an already-gated issue) is gated — any other
|
|
345
|
+
// status is a racer or a non-parked issue; do not invent an action there,
|
|
346
|
+
// but do not let the merge through either — park the finalize as a no-op.
|
|
347
|
+
if (current.status !== "final_review" && current.status !== "needs_human") {
|
|
348
|
+
return {
|
|
349
|
+
applied: true,
|
|
350
|
+
issueStatus: current.status,
|
|
351
|
+
nextWorkItemId: null,
|
|
352
|
+
humanActionId: null,
|
|
353
|
+
instanceCompleted: false,
|
|
354
|
+
triggerReflect: false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// No head SHA to pin the verification to (no handoff recorded one): the
|
|
358
|
+
// gate cannot name a head, so escalate instead of merging — retry_merge
|
|
359
|
+
// re-enters this same finalize once a head exists and gates normally.
|
|
360
|
+
// Repeat finalizes reuse the one open escalation (no duplicate action or
|
|
361
|
+
// event), and the transition is skipped when already parked
|
|
362
|
+
// (needs_human has no self-loop).
|
|
363
|
+
if (!head) {
|
|
364
|
+
const retry = findOpenHumanActionByRequestId(issueId, "policy_escalation", OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID);
|
|
365
|
+
if (retry) {
|
|
366
|
+
if (current.status !== "needs_human") {
|
|
367
|
+
transitionIssue(issueId, "needs_human", {
|
|
368
|
+
currentOwner: "human",
|
|
369
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
applied: true,
|
|
374
|
+
issueStatus: "needs_human",
|
|
375
|
+
nextWorkItemId: null,
|
|
376
|
+
humanActionId: retry.id,
|
|
377
|
+
instanceCompleted: false,
|
|
378
|
+
triggerReflect: false,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const reason = `Reviewer approved, but ${criteria.length} acceptance ` +
|
|
382
|
+
`${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
|
|
383
|
+
`to verify (tagged [operator] in the frozen task snapshot) and no head SHA is ` +
|
|
384
|
+
`recorded to pin the verification to. The PR stays unmerged.`;
|
|
385
|
+
if (current.status !== "needs_human") {
|
|
386
|
+
transitionIssue(issueId, "needs_human", {
|
|
387
|
+
currentOwner: "human",
|
|
388
|
+
currentIntent: reason,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const action = createHumanAction({
|
|
392
|
+
issueId,
|
|
393
|
+
workflowInstanceId: active.id,
|
|
394
|
+
actionType: "policy_escalation",
|
|
395
|
+
reason,
|
|
396
|
+
question: `${reason} Retry the merge once a head is recorded, queue another repair round, or close the issue?`,
|
|
397
|
+
evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true, operatorVerificationBlocked: true },
|
|
398
|
+
responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
|
|
399
|
+
requestId: OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID,
|
|
400
|
+
});
|
|
401
|
+
appendWorkflowEvent({
|
|
402
|
+
issueId,
|
|
403
|
+
workflowInstanceId: active.id,
|
|
404
|
+
workerSessionId: null,
|
|
405
|
+
type: "human_action.requested",
|
|
406
|
+
actorType: "system",
|
|
407
|
+
stage: "needs_human",
|
|
408
|
+
round: current.currentRound,
|
|
409
|
+
payload: { actionType: "policy_escalation", actionId: action.id, operatorVerificationBlocked: true },
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
applied: true,
|
|
413
|
+
issueStatus: "needs_human",
|
|
414
|
+
nextWorkItemId: null,
|
|
415
|
+
humanActionId: action.id,
|
|
416
|
+
instanceCompleted: false,
|
|
417
|
+
triggerReflect: false,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const existing = findOpenHumanAction(issueId, "operator_verification");
|
|
421
|
+
if (existing && operatorActionHeadSha(existing) === head) {
|
|
422
|
+
if (current.status !== "needs_human") {
|
|
423
|
+
transitionIssue(issueId, "needs_human", {
|
|
424
|
+
currentOwner: "human",
|
|
425
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
applied: true,
|
|
430
|
+
issueStatus: "needs_human",
|
|
431
|
+
nextWorkItemId: null,
|
|
432
|
+
humanActionId: existing.id,
|
|
433
|
+
instanceCompleted: false,
|
|
434
|
+
triggerReflect: false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const shortHead = head.slice(0, 8);
|
|
438
|
+
const reason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
|
|
439
|
+
`${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
|
|
440
|
+
`to verify (tagged [operator] in the frozen task snapshot) and no result is recorded ` +
|
|
441
|
+
`for this head. The PR stays unmerged until the result is recorded:\n` +
|
|
442
|
+
formatOperatorCriteria(criteria).join("\n");
|
|
443
|
+
transitionIssue(issueId, "needs_human", {
|
|
444
|
+
currentOwner: "human",
|
|
445
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
446
|
+
});
|
|
447
|
+
// Head-pinned request_id: a concurrent finalize for the same head dedupes
|
|
448
|
+
// onto this action through createHumanAction's open-request conflict path;
|
|
449
|
+
// new commits after the gate get a fresh action with their own commands.
|
|
450
|
+
const action = createHumanAction({
|
|
451
|
+
issueId,
|
|
452
|
+
workflowInstanceId: active.id,
|
|
453
|
+
actionType: "operator_verification",
|
|
454
|
+
reason,
|
|
455
|
+
question: `${reason}\n\nPaste the probe output to record verification, waive with a reason, ` +
|
|
456
|
+
`or send the work back for another repair round?`,
|
|
457
|
+
evidence: { operatorVerification: { criteria, headSha: head } },
|
|
458
|
+
responseOptions: [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS],
|
|
459
|
+
requestId: operatorVerificationRequestId(head),
|
|
460
|
+
});
|
|
461
|
+
appendWorkflowEvent({
|
|
462
|
+
issueId,
|
|
463
|
+
workflowInstanceId: active.id,
|
|
464
|
+
workerSessionId: null,
|
|
465
|
+
type: "human_action.requested",
|
|
466
|
+
actorType: "system",
|
|
467
|
+
stage: "needs_human",
|
|
468
|
+
round: current.currentRound,
|
|
469
|
+
payload: { actionType: "operator_verification", actionId: action.id, headSha: head },
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
applied: true,
|
|
473
|
+
issueStatus: "needs_human",
|
|
474
|
+
nextWorkItemId: null,
|
|
475
|
+
humanActionId: action.id,
|
|
476
|
+
instanceCompleted: false,
|
|
477
|
+
triggerReflect: false,
|
|
478
|
+
};
|
|
479
|
+
})();
|
|
480
|
+
}
|
|
481
|
+
/** The head SHA an `operator_verification` action gates (null when unreadable). */
|
|
482
|
+
function operatorActionHeadSha(action) {
|
|
483
|
+
if (!action.evidenceJson)
|
|
484
|
+
return null;
|
|
485
|
+
try {
|
|
486
|
+
const evidence = JSON.parse(action.evidenceJson);
|
|
487
|
+
const headSha = evidence.operatorVerification?.headSha;
|
|
488
|
+
return typeof headSha === "string" && headSha ? headSha : null;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
290
494
|
function escalateMergeFailure(issue, workflowInstanceId, reason, evidence = { [MERGE_FAILURE_EVIDENCE_KEY]: true }) {
|
|
291
495
|
return getDb().transaction(() => {
|
|
292
496
|
const current = getIssue(issue.id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// packages/server/src/coordinator/checks-wait-config.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-311: how long one work item may wait on CI before the wait itself escalates.
|
|
4
|
+
// The per-poll bound (CHECKS_POLL_TIMEOUT_MS, default 10 min) only ends a single
|
|
5
|
+
// poll with a `checks_pending` deferral; this ceiling bounds the whole wait measured
|
|
6
|
+
// from the first poll, across deferrals. Past it the item stops re-queueing and a
|
|
7
|
+
// human is asked to look at the stuck CI run instead of waiting forever.
|
|
8
|
+
/** Overall CI-wait ceiling measured from the first checks poll (default 2 h). */
|
|
9
|
+
export function checksWaitCeilingMs() {
|
|
10
|
+
const parsed = Number(process.env.CHECKS_WAIT_CEILING_MS ?? 2 * 60 * 60_000);
|
|
11
|
+
return Number.isFinite(parsed) ? parsed : 2 * 60 * 60_000;
|
|
12
|
+
}
|
|
13
|
+
/** True once `nowMs` is at or past the ceiling after the first wait started. */
|
|
14
|
+
export function checksWaitCeilingExceeded(firstWaitStartedAt, nowMs = Date.now()) {
|
|
15
|
+
const start = Date.parse(firstWaitStartedAt);
|
|
16
|
+
if (!Number.isFinite(start))
|
|
17
|
+
return false;
|
|
18
|
+
return nowMs - start >= checksWaitCeilingMs();
|
|
19
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import { canTransitionIssue, tryCompileContract } from "@agent-dealer/shared";
|
|
11
11
|
import { getDb } from "../db/index.js";
|
|
12
|
-
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueInfraAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
12
|
+
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, incrementIssueCiAttempts, resetIssueInfraAttempts, resetIssueCiAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
13
13
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
|
|
14
14
|
import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, reopenHumanAction, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
|
|
15
15
|
import { pushLeaseToSha, readRemoteTip } from "../adapters/git-worktree.js";
|
|
@@ -20,7 +20,7 @@ import { getAgent } from "../repository/agents.js";
|
|
|
20
20
|
import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-health.js";
|
|
21
21
|
import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
|
|
22
22
|
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
23
|
-
import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
|
|
23
|
+
import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
|
|
24
24
|
import { completeSession, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
25
25
|
import { killRunProcess } from "../runners/spawn-cli.js";
|
|
26
26
|
import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
|
|
@@ -31,9 +31,10 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
|
31
31
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
32
32
|
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
33
33
|
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
34
|
+
import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
|
|
34
35
|
import { externalMergeStateForIssue } from "./external-merge.js";
|
|
35
36
|
import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
|
|
36
|
-
import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
37
|
+
import { capEscalationEvents, checksWaitDeferralStartedAt, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForChecksPending, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, formatChecksWaitEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
37
38
|
import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
|
|
38
39
|
import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
|
|
39
40
|
import { getWorkflow } from "./workflows/registry.js";
|
|
@@ -136,7 +137,11 @@ function freezeTaskSnapshot(issue) {
|
|
|
136
137
|
* never changes the eventual session. The worker loop consumes this off the payload and
|
|
137
138
|
* only falls back to a live resolve for a legacy item queued before snapshots were carried.
|
|
138
139
|
*/
|
|
139
|
-
|
|
140
|
+
/**
|
|
141
|
+
* NOT-333: shared with recovery's stranded-reviewer re-enqueue so a recovered round
|
|
142
|
+
* carries the same frozen profile as a normally routed one.
|
|
143
|
+
*/
|
|
144
|
+
export function queuedProfileSnapshot(issue, kind) {
|
|
140
145
|
const agentId = kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
141
146
|
const agent = agentId ? getAgent(agentId) : null;
|
|
142
147
|
return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, kind)) : null;
|
|
@@ -327,6 +332,12 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
|
|
|
327
332
|
if (outcome.kind === "base_fetch_failed") {
|
|
328
333
|
return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
|
|
329
334
|
}
|
|
335
|
+
// NOT-311: CI still queued/running (or not yet registered) at the poll timeout —
|
|
336
|
+
// the session ended cleanly, so the item waits on backoff and re-polls instead of
|
|
337
|
+
// spending an infra attempt. Past the wait ceiling this escalates to a human.
|
|
338
|
+
if (outcome.kind === "checks_pending") {
|
|
339
|
+
return applyChecksPendingCompletion(workItemId, leaseToken, outcome);
|
|
340
|
+
}
|
|
330
341
|
const routed = getDb().transaction(() => {
|
|
331
342
|
const before = getWorkItem(workItemId);
|
|
332
343
|
if (!before)
|
|
@@ -415,6 +426,68 @@ function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
|
|
|
415
426
|
function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
|
|
416
427
|
return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
|
|
417
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* NOT-311: CI still pending at the poll timeout — defer with backoff, no attempt
|
|
431
|
+
* spent. Unlike a deck outage the wait has a ceiling, so the escalation arm raises a
|
|
432
|
+
* human action naming the stuck PR/SHA once CI has waited too long.
|
|
433
|
+
*/
|
|
434
|
+
function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
|
|
435
|
+
return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
|
|
439
|
+
* CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling
|
|
440
|
+
* escalation (worker.failed + needs_human + policy_escalation).
|
|
441
|
+
*/
|
|
442
|
+
export function routeChecksWaitEscalation(issue, instance, item, pending) {
|
|
443
|
+
const payload = parseWorkItemPayload(item.payloadJson);
|
|
444
|
+
const firstDeferredAt = checksWaitDeferralStartedAt(payload) ?? pending.waitStartedAt;
|
|
445
|
+
const reason = formatChecksWaitEscalationReason(pending, firstDeferredAt);
|
|
446
|
+
const role = item.kind === "developer" ? "developer" : "reviewer";
|
|
447
|
+
const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
|
|
448
|
+
for (const type of capEscalationEvents()) {
|
|
449
|
+
const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
|
|
450
|
+
ev.emit(type, {
|
|
451
|
+
actorType: role,
|
|
452
|
+
payload: {
|
|
453
|
+
...workerSessionPayload({
|
|
454
|
+
runtime: session?.runtime ?? null,
|
|
455
|
+
model: session?.model ?? null,
|
|
456
|
+
sessionId: item.workerSessionId ?? "",
|
|
457
|
+
worktreePath: session?.worktreePath,
|
|
458
|
+
}),
|
|
459
|
+
outcome: "checks_pending",
|
|
460
|
+
reason: pending.reason,
|
|
461
|
+
branch: pending.branch,
|
|
462
|
+
prNumber: pending.prNumber,
|
|
463
|
+
headSha: pending.headSha,
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
applyProjectionTransition(issue, {
|
|
468
|
+
issueStatus: "needs_human",
|
|
469
|
+
currentOwner: "human",
|
|
470
|
+
currentIntent: reason,
|
|
471
|
+
events: capEscalationEvents(),
|
|
472
|
+
}, {});
|
|
473
|
+
const action = createHumanAction({
|
|
474
|
+
issueId: issue.id,
|
|
475
|
+
workflowInstanceId: instance.id,
|
|
476
|
+
actionType: "policy_escalation",
|
|
477
|
+
reason,
|
|
478
|
+
question: questionFor("policy_escalation", reason),
|
|
479
|
+
evidence: { checksPending: { ...pending, firstDeferredAt } },
|
|
480
|
+
responseOptions: responseOptionsFor("policy_escalation"),
|
|
481
|
+
});
|
|
482
|
+
ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
|
|
483
|
+
return {
|
|
484
|
+
applied: true,
|
|
485
|
+
issueStatus: "needs_human",
|
|
486
|
+
nextWorkItemId: null,
|
|
487
|
+
humanActionId: action.id,
|
|
488
|
+
instanceCompleted: false,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
418
491
|
/**
|
|
419
492
|
* Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
|
|
420
493
|
* Used when applyCompletion or the worker loop cannot defer any longer.
|
|
@@ -525,6 +598,8 @@ function applyDeveloper(issue, instance, item, outcome) {
|
|
|
525
598
|
maxReviewRounds: issue.maxReviewRounds,
|
|
526
599
|
infraAttempts: issue.infraAttempts,
|
|
527
600
|
maxInfraAttempts: issue.maxInfraAttempts,
|
|
601
|
+
ciAttempts: issue.ciAttempts,
|
|
602
|
+
maxCiAttempts: issue.maxCiAttempts,
|
|
528
603
|
});
|
|
529
604
|
const { projection, effect, advance } = projectDeveloperRoute(route, issue.status, issue.currentRound);
|
|
530
605
|
const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
|
|
@@ -607,6 +682,9 @@ function applyDeveloper(issue, instance, item, outcome) {
|
|
|
607
682
|
incrementIssueRound(issue.id);
|
|
608
683
|
else if (advance === "infra")
|
|
609
684
|
incrementIssueInfraAttempts(issue.id);
|
|
685
|
+
// NOT-313: a CI-repair retry spends ci_attempts, never infra_attempts.
|
|
686
|
+
else if (advance === "ci")
|
|
687
|
+
incrementIssueCiAttempts(issue.id);
|
|
610
688
|
const issueNow = getIssue(issue.id);
|
|
611
689
|
return applyEffect(issue, instance, effect, route, issueNow, ev, item.id);
|
|
612
690
|
}
|
|
@@ -740,9 +818,10 @@ function findNonConvergence(issue, instance, result) {
|
|
|
740
818
|
});
|
|
741
819
|
return detectNonConvergence(history, issue.currentRound, floorRound);
|
|
742
820
|
}
|
|
743
|
-
/** Every route that spends an infra attempt (projection.ts's advance === "infra"
|
|
744
|
-
* the same round/head, so its enqueue needs a suffix the plain
|
|
745
|
-
* provide to avoid colliding with the previous (terminal)
|
|
821
|
+
/** Every route that spends an infra or CI attempt (projection.ts's advance === "infra"
|
|
822
|
+
* or "ci") repeats the same round/head, so its enqueue needs a suffix the plain
|
|
823
|
+
* round/head key doesn't provide to avoid colliding with the previous (terminal)
|
|
824
|
+
* attempt's key.
|
|
746
825
|
* retry_reviewer_at_new_head belongs here too — a head that cycles A→B→A would otherwise
|
|
747
826
|
* re-derive attempt A's ORIGINAL key (same round, same head) and collide with that
|
|
748
827
|
* now-terminal work item, leaving the issue "reviewing" with no pending item. */
|
|
@@ -773,6 +852,21 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
773
852
|
const attemptSuffix = isInfraRetry(route) ? `:retry-of:${causativeItemId}` : "";
|
|
774
853
|
if (route.next === "retry_developer_with_findings")
|
|
775
854
|
ev.emit("repair.started");
|
|
855
|
+
let idempotencyKey = `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`;
|
|
856
|
+
// NOT-333: a developer round that pushes nothing (e.g. a resume round that only
|
|
857
|
+
// updates the PR description) re-derives the exact key of the already-terminal
|
|
858
|
+
// reviewer item it follows — same instance, same round, same head — so the enqueue
|
|
859
|
+
// dedupes against that done row and strands the issue in `reviewing` with no pending
|
|
860
|
+
// work. A terminal duplicate gets the same `:retry-of:<causative work item id>`
|
|
861
|
+
// shape the infra-retry path uses, so a new row is created; a still-active
|
|
862
|
+
// (pending/leased) duplicate keeps deduping.
|
|
863
|
+
if (kind === "reviewer" && attemptSuffix === "") {
|
|
864
|
+
const existing = getWorkItemByIdempotencyKey(idempotencyKey);
|
|
865
|
+
if (existing &&
|
|
866
|
+
(existing.status === "done" || existing.status === "dead" || existing.status === "cancelled")) {
|
|
867
|
+
idempotencyKey += `:retry-of:${causativeItemId}`;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
776
870
|
const next = enqueueWorkItem({
|
|
777
871
|
issueId: issue.id,
|
|
778
872
|
workflowInstanceId: instance.id,
|
|
@@ -785,7 +879,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
785
879
|
...(effect.branch ? { branch: effect.branch } : {}),
|
|
786
880
|
profileSnapshot: queuedProfileSnapshot(issue, kind),
|
|
787
881
|
},
|
|
788
|
-
idempotencyKey
|
|
882
|
+
idempotencyKey,
|
|
789
883
|
});
|
|
790
884
|
return { ...base, nextWorkItemId: next.id };
|
|
791
885
|
}
|
|
@@ -996,6 +1090,12 @@ function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure
|
|
|
996
1090
|
// directly with its own question text (NOT-308). Case exists only so this
|
|
997
1091
|
// function stays total over HumanActionType.
|
|
998
1092
|
throw new Error("muse_capability is not raised through applyEffect");
|
|
1093
|
+
case "operator_verification":
|
|
1094
|
+
// Never raised through applyEffect — auto-merge.ts's operator gate creates
|
|
1095
|
+
// this action directly with a question listing each criterion and its
|
|
1096
|
+
// command (NOT-314). Case exists only so this function stays total over
|
|
1097
|
+
// HumanActionType.
|
|
1098
|
+
throw new Error("operator_verification is not raised through applyEffect");
|
|
999
1099
|
}
|
|
1000
1100
|
}
|
|
1001
1101
|
/** Exported for db/migrate-to-issues.ts, which must populate the same response options on
|
|
@@ -1042,6 +1142,11 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false, opts =
|
|
|
1042
1142
|
// admits on that Muse version (missing) or dismisses (unverified). Never raised
|
|
1043
1143
|
// through applyEffect; case exists only so this function stays total.
|
|
1044
1144
|
return [{ choice: "acknowledge", label: "Acknowledge" }];
|
|
1145
|
+
case "operator_verification":
|
|
1146
|
+
// Raised by auto-merge.ts's operator gate with these exact options (the
|
|
1147
|
+
// shared constant keeps the two from drifting); listed here so the table
|
|
1148
|
+
// stays total over HumanActionType.
|
|
1149
|
+
return [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS];
|
|
1045
1150
|
}
|
|
1046
1151
|
}
|
|
1047
1152
|
function parseContinuationPreview(json) {
|
|
@@ -1080,6 +1185,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1080
1185
|
const normalizedNote = normalizeResolutionNote(opts?.note);
|
|
1081
1186
|
if (!normalizedNote.ok)
|
|
1082
1187
|
return { ok: false, code: 400, error: normalizedNote.error };
|
|
1188
|
+
// NOT-314: every operator_verification choice requires a non-empty note — the
|
|
1189
|
+
// recorded result (verified), the waiver reason (waive), or what to fix
|
|
1190
|
+
// (repair). parseHumanResolution below also rejects a missing note; this
|
|
1191
|
+
// names the requirement instead of misreporting a valid choice as invalid.
|
|
1192
|
+
if (action.actionType === "operator_verification" && !normalizedNote.note) {
|
|
1193
|
+
return {
|
|
1194
|
+
ok: false,
|
|
1195
|
+
code: 400,
|
|
1196
|
+
error: 'A non-empty note is required to resolve operator_verification (verified: the result, e.g. pasted output; waive: why it is waived; repair: what the next round must fix)',
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1083
1199
|
// NOT-308: a Muse capability escalation resolves without touching the workflow — it
|
|
1084
1200
|
// has no round to spend and no merge to park, and parseHumanResolution (below)
|
|
1085
1201
|
// deliberately rejects it since there is no workflow outcome to map. Acknowledging a
|
|
@@ -1140,13 +1256,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1140
1256
|
return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
|
|
1141
1257
|
}
|
|
1142
1258
|
// NOT-272: the typed resolution carries the note only for product_scope_decision
|
|
1143
|
-
// (parseHumanResolution drops it for every other action type
|
|
1259
|
+
// (parseHumanResolution drops it for every other action type — except
|
|
1260
|
+
// operator_verification, whose note is required and always carried).
|
|
1144
1261
|
const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
|
|
1145
1262
|
? resolution.note
|
|
1146
1263
|
: undefined;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1264
|
+
// NOT-314: the repair instruction rides the next developer round's payload;
|
|
1265
|
+
// the verified/waived note is stored on the artifact / waiver event below.
|
|
1266
|
+
const operatorNote = resolution.actionType === "operator_verification" && "note" in resolution
|
|
1267
|
+
? resolution.note
|
|
1268
|
+
: undefined;
|
|
1269
|
+
const storedResolution = scopeDecisionNote ?? operatorNote ? { choice, note: scopeDecisionNote ?? operatorNote } : { choice };
|
|
1150
1270
|
// NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
|
|
1151
1271
|
// retry_merge/repair/close only (resume would re-run development on approved work);
|
|
1152
1272
|
// every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
|
|
@@ -1191,12 +1311,56 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1191
1311
|
// Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
|
|
1192
1312
|
// NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
|
|
1193
1313
|
// first, so a second failure escalates exactly one fresh action with the new reason.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1314
|
+
// NOT-314: an operator_verification verified/waive parks the same way — the recorded
|
|
1315
|
+
// result satisfies the gate, so finalizeAutoMerge merges on the very next pass.
|
|
1316
|
+
// (repair takes the generic repair-round path below, never this park.)
|
|
1317
|
+
const parksForMerge = (resolution.actionType === "final_review" &&
|
|
1318
|
+
(resolution.choice === "merge" || resolution.choice === "complete")) ||
|
|
1319
|
+
(resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge") ||
|
|
1320
|
+
(resolution.actionType === "operator_verification" &&
|
|
1321
|
+
(resolution.choice === "verified" || resolution.choice === "waive"));
|
|
1322
|
+
if (instance && parksForMerge) {
|
|
1198
1323
|
return getDb().transaction(() => {
|
|
1199
|
-
resolveHumanAction(actionId, resolvedBy,
|
|
1324
|
+
resolveHumanAction(actionId, resolvedBy, storedResolution);
|
|
1325
|
+
// NOT-314: durably record what the merge is authorized by, in the same txn as
|
|
1326
|
+
// the park — `verified` stores the result as an issue artifact (kind
|
|
1327
|
+
// operator_verification, author human) pinned to this head; `waive`
|
|
1328
|
+
// appends an operator_verification.waived workflow event. A later finalize
|
|
1329
|
+
// re-checks the record against the then-current head, so new commits
|
|
1330
|
+
// re-block the gate.
|
|
1331
|
+
if (resolution.actionType === "operator_verification" && operatorNote) {
|
|
1332
|
+
if (resolution.choice === "verified") {
|
|
1333
|
+
createIssueArtifact({
|
|
1334
|
+
issueId: issue.id,
|
|
1335
|
+
kind: OPERATOR_VERIFICATION_ARTIFACT_KIND,
|
|
1336
|
+
author: "human",
|
|
1337
|
+
content: {
|
|
1338
|
+
headSha: issue.headSha,
|
|
1339
|
+
note: operatorNote,
|
|
1340
|
+
verifiedBy: resolvedBy,
|
|
1341
|
+
verifiedAt: new Date().toISOString(),
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
appendWorkflowEvent({
|
|
1347
|
+
issueId: issue.id,
|
|
1348
|
+
workflowInstanceId: instance.id,
|
|
1349
|
+
type: "operator_verification.waived",
|
|
1350
|
+
actorType: "human",
|
|
1351
|
+
actorRef: resolvedBy,
|
|
1352
|
+
stage: issue.status,
|
|
1353
|
+
round: issue.currentRound,
|
|
1354
|
+
payload: {
|
|
1355
|
+
actionType: "operator_verification",
|
|
1356
|
+
choice: "waive",
|
|
1357
|
+
headSha: issue.headSha,
|
|
1358
|
+
waivedBy: resolvedBy,
|
|
1359
|
+
note: operatorNote,
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1200
1364
|
appendWorkflowEvent({
|
|
1201
1365
|
issueId: issue.id,
|
|
1202
1366
|
workflowInstanceId: instance.id,
|
|
@@ -1205,7 +1369,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1205
1369
|
actorRef: resolvedBy,
|
|
1206
1370
|
stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
|
|
1207
1371
|
round: issue.currentRound,
|
|
1208
|
-
payload: {
|
|
1372
|
+
payload: {
|
|
1373
|
+
actionType: action.actionType,
|
|
1374
|
+
choice: resolution.choice,
|
|
1375
|
+
pendingMerge: true,
|
|
1376
|
+
...(operatorNote ? { note: operatorNote } : {}),
|
|
1377
|
+
},
|
|
1209
1378
|
});
|
|
1210
1379
|
transitionIssue(issue.id, "final_review", {
|
|
1211
1380
|
currentOwner: "system",
|
|
@@ -1411,7 +1580,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1411
1580
|
incrementIssueRound(issue.id);
|
|
1412
1581
|
break;
|
|
1413
1582
|
case "infra":
|
|
1583
|
+
// NOT-313: a resume past an infra OR CI-checks escalation grants a fresh
|
|
1584
|
+
// budget of both kinds — the CI budget is not a review-round spend either.
|
|
1414
1585
|
resetIssueInfraAttempts(issue.id);
|
|
1586
|
+
resetIssueCiAttempts(issue.id);
|
|
1415
1587
|
if (advancesRound)
|
|
1416
1588
|
incrementIssueRound(issue.id);
|
|
1417
1589
|
break;
|
|
@@ -1455,6 +1627,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1455
1627
|
// NOT-272: a product_scope_decision note rides only this next round's
|
|
1456
1628
|
// payload — later rounds never see it.
|
|
1457
1629
|
...(scopeDecisionNote ? { scopeDecisionNote } : {}),
|
|
1630
|
+
// NOT-314: same one-shot ride for an operator_verification repair
|
|
1631
|
+
// note — only the repair choice reaches this path (verified/waive
|
|
1632
|
+
// park for merge above), and only this round reads it.
|
|
1633
|
+
...(operatorNote ? { operatorRepairNote: operatorNote } : {}),
|
|
1458
1634
|
},
|
|
1459
1635
|
idempotencyKey: resumeKey,
|
|
1460
1636
|
});
|
|
@@ -635,6 +635,53 @@ test("fail → retry → exhaust → resume → fail again does not collide with
|
|
|
635
635
|
assert.equal(pending.length, 1, "a fresh item must be enqueued — the old (round, infraAttempts)-keyed row must not be silently reused");
|
|
636
636
|
assert.notEqual(pending[0].id, firstRetryItem.id, "must be a NEW work item, not the pre-escalation retry's now-terminal row");
|
|
637
637
|
});
|
|
638
|
+
test("NOT-333: a resume round that pushes nothing still enqueues a fresh reviewer instead of deduping against the terminal one", async () => {
|
|
639
|
+
// The NOT-200 shape: reviewer round 1 at head H escalates with a product scope
|
|
640
|
+
// question, the human resumes, and the developer round changes nothing — so its
|
|
641
|
+
// handoff re-derives the exact idempotency key of the already-done reviewer item.
|
|
642
|
+
const issueId = newIssue();
|
|
643
|
+
startWorkflow(issueId);
|
|
644
|
+
await complete(issueId, cleanHandoff);
|
|
645
|
+
const firstReviewer = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer");
|
|
646
|
+
await complete(issueId, {
|
|
647
|
+
kind: "verdict",
|
|
648
|
+
result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
|
|
649
|
+
});
|
|
650
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
|
|
651
|
+
const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
|
|
652
|
+
assert.equal(resolved.ok, true);
|
|
653
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
654
|
+
// The resume round pushes nothing: the handoff carries the very same head SHA.
|
|
655
|
+
const { result } = await complete(issueId, cleanHandoff);
|
|
656
|
+
assert.equal(result.applied, true);
|
|
657
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
658
|
+
assert.equal(getWorkItem(firstReviewer.id).status, "done");
|
|
659
|
+
const reviewers = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer");
|
|
660
|
+
const pending = reviewers.filter((i) => i.status === "pending");
|
|
661
|
+
assert.equal(pending.length, 1, "a fresh reviewer round must be queued, not dropped via a key collision");
|
|
662
|
+
assert.notEqual(pending[0].id, firstReviewer.id, "must be a NEW work item, not the escalated round's now-terminal row");
|
|
663
|
+
assert.match(pending[0].idempotencyKey, /:retry-of:/, "the terminal duplicate is disambiguated like an infra retry");
|
|
664
|
+
assert.equal(JSON.parse(pending[0].payloadJson).inputSha, cleanHandoff.headSha);
|
|
665
|
+
});
|
|
666
|
+
test("NOT-333: a duplicate developer completion while the reviewer is still pending or leased enqueues no second reviewer", async () => {
|
|
667
|
+
const issueId = newIssue();
|
|
668
|
+
startWorkflow(issueId);
|
|
669
|
+
const item = claim(issueId);
|
|
670
|
+
const first = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
671
|
+
assert.equal(first.applied, true);
|
|
672
|
+
// A retried callback redelivers the same completion while the reviewer is pending.
|
|
673
|
+
const second = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
674
|
+
assert.equal(second.applied, false);
|
|
675
|
+
// Same while the reviewer is leased — still exactly one active reviewer row.
|
|
676
|
+
const reviewer = claim(issueId);
|
|
677
|
+
assert.equal(reviewer.kind, "reviewer");
|
|
678
|
+
const third = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
679
|
+
assert.equal(third.applied, false);
|
|
680
|
+
const active = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && (i.status === "pending" || i.status === "leased"));
|
|
681
|
+
assert.equal(active.length, 1);
|
|
682
|
+
assert.equal(active[0].id, reviewer.id);
|
|
683
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 1);
|
|
684
|
+
});
|
|
638
685
|
test("resolving product_scope_decision:resume after a reviewer's escalated+question resumes as the developer", async () => {
|
|
639
686
|
const issueId = newIssue();
|
|
640
687
|
startWorkflow(issueId);
|