@zq-silk/yui 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  77. package/dist/task/completionReadiness.js +282 -0
  78. package/dist/task/publicationReference.js +123 -0
  79. package/dist/task/taskRecordReference.js +3 -1
  80. package/dist/telemetry/telemetryConfig.js +23 -18
  81. package/dist/telemetry/telemetryWiring.js +8 -8
  82. package/dist/tmux/tmuxManager.js +50 -9
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/view.js +18 -0
  85. package/dist/web/webSnapshot.js +100 -10
  86. package/i18n/README.zh-CN.md +5 -5
  87. package/package.json +1 -1
  88. package/skills/yui-leader/SKILL.md +49 -10
  89. package/skills/yui-operator/SKILL.md +13 -3
  90. package/skills/yui-worker/SKILL.md +8 -0
@@ -0,0 +1,232 @@
1
+ import { createHash } from "node:crypto";
2
+ import { deltaRecheckMaxChangedFiles, deltaRecheckMaxChangedLines } from "./reviewConfig.js";
3
+ import { validateDeltaRecheckRecord } from "./reviewRound.js";
4
+ /**
5
+ * Assesses whether a delta-recheck may be attempted. Every deterministic
6
+ * gate fails closed here; semantic equivalence is always left to the
7
+ * Reviewer's explicit disposition.
8
+ */
9
+ export async function assessDeltaRecheck(input) {
10
+ const { repositoryPath, previousRound, candidate, git, config } = input;
11
+ if (previousRound.status !== "completed"
12
+ || (previousRound.scope ?? "work-item") !== "task") {
13
+ return {
14
+ kind: "ineligible",
15
+ reason: "Delta recheck requires a completed Task-final ReviewRound."
16
+ };
17
+ }
18
+ if (previousRound.taskCandidate === undefined) {
19
+ return {
20
+ kind: "ineligible",
21
+ reason: "Previous Task-final ReviewRound has no frozen candidate."
22
+ };
23
+ }
24
+ const previousByProject = new Map(previousRound.taskCandidate.projects.map((project) => [project.projectId, project.commit]));
25
+ const diffByProject = {};
26
+ const changedFiles = [];
27
+ let addedLines = 0;
28
+ let deletedLines = 0;
29
+ let anyChange = false;
30
+ for (const project of candidate.projects) {
31
+ const previousHead = previousByProject.get(project.projectId);
32
+ if (previousHead === undefined) {
33
+ return {
34
+ kind: "ineligible",
35
+ reason: `Delta recheck scope changed: Project ${project.projectId} was not in the previous Review.`
36
+ };
37
+ }
38
+ if (previousHead === project.commit)
39
+ continue;
40
+ if (!await git.isAncestor(repositoryPath, previousHead, project.commit)) {
41
+ return {
42
+ kind: "ineligible",
43
+ reason: `Delta recheck requires a contiguous base: ${project.projectId} ${previousHead} `
44
+ + `is not an ancestor of ${project.commit}.`
45
+ };
46
+ }
47
+ let files;
48
+ let numstat;
49
+ let diffText;
50
+ try {
51
+ files = await git.changedFilesBetween({
52
+ repositoryPath,
53
+ fromCommit: previousHead,
54
+ toCommit: project.commit
55
+ });
56
+ numstat = await git.diffNumstatBetween({
57
+ repositoryPath,
58
+ fromCommit: previousHead,
59
+ toCommit: project.commit
60
+ });
61
+ diffText = await git.diffTextBetween({
62
+ repositoryPath,
63
+ fromCommit: previousHead,
64
+ toCommit: project.commit
65
+ });
66
+ }
67
+ catch (error) {
68
+ return {
69
+ kind: "ineligible",
70
+ reason: `Delta recheck cannot assess the diff for ${project.projectId}: `
71
+ + `${error instanceof Error ? error.message : String(error)}`
72
+ };
73
+ }
74
+ diffByProject[project.projectId] = diffText;
75
+ changedFiles.push(...files);
76
+ addedLines += numstat.addedLines;
77
+ deletedLines += numstat.deletedLines;
78
+ anyChange = true;
79
+ }
80
+ if (!anyChange) {
81
+ return {
82
+ kind: "ineligible",
83
+ reason: "Frozen heads are unchanged; the previous acceptance already covers this candidate."
84
+ };
85
+ }
86
+ const maxFiles = deltaRecheckMaxChangedFiles(config);
87
+ if (changedFiles.length > maxFiles) {
88
+ return {
89
+ kind: "ineligible",
90
+ reason: `Delta recheck attempt threshold exceeded: ${changedFiles.length} changed file(s) > ${maxFiles}.`
91
+ };
92
+ }
93
+ const maxLines = deltaRecheckMaxChangedLines(config);
94
+ if (addedLines + deletedLines > maxLines) {
95
+ return {
96
+ kind: "ineligible",
97
+ reason: `Delta recheck attempt threshold exceeded: ${addedLines + deletedLines} changed line(s) > ${maxLines}.`
98
+ };
99
+ }
100
+ // Evidence overlap: a diff touching a file the previous Round cited as
101
+ // evidence cannot be proven equivalent by a delta recheck.
102
+ const evidencePaths = extractEvidencePaths(previousRound);
103
+ const overlapping = changedFiles.filter((file) => (evidencePaths.some((evidence) => pathEvidenceMatches(evidence, file))));
104
+ if (overlapping.length > 0) {
105
+ return {
106
+ kind: "ineligible",
107
+ reason: "Delta recheck cannot prove evidence validity for changed evidence file(s): "
108
+ + `${overlapping.join(", ")}.`
109
+ };
110
+ }
111
+ const record = validateDeltaRecheckRecord({
112
+ schemaVersion: 1,
113
+ previousReviewRoundId: previousRound.id,
114
+ previousBaseCommit: previousRound.taskCandidate.projects[0].commit,
115
+ diffDigest: digestDiff(diffByProject),
116
+ changedFiles,
117
+ addedLines,
118
+ deletedLines
119
+ });
120
+ return { kind: "eligible", preflight: { record, diffByProject } };
121
+ }
122
+ /** Fails closed when the dispatch diff does not match the recorded digest. */
123
+ export function verifyDeltaRecheckDiff(record, diffByProject) {
124
+ if (digestDiff(diffByProject) !== record.diffDigest) {
125
+ throw new Error("Delta recheck diff digest does not match the recorded recheck.");
126
+ }
127
+ }
128
+ /** Builds the delta-recheck dispatch context block. */
129
+ export function buildDeltaRecheckDispatchContext(input) {
130
+ const { round, previousRound, diffByProject, ledgerContext } = input;
131
+ const record = round.deltaRecheck;
132
+ if (record === undefined) {
133
+ throw new Error(`ReviewRound is not a delta-recheck: ${round.id}.`);
134
+ }
135
+ const lines = [
136
+ "Review mode: delta-recheck (Issue 07). This is a fresh Review of the new frozen head;",
137
+ "the previous acceptance is evidence, not a shortcut. You may return exactly one disposition:",
138
+ "- equivalent-and-accepted: you proved the diff preserves every accepted semantic and",
139
+ " every evidence reference below remains valid. State the proof explicitly.",
140
+ "- finding: the diff introduces a material problem; report it as a finding.",
141
+ "- requires-full-review: you cannot prove equivalence (uncertainty, cross-scope,",
142
+ " semantic change, evidence doubt). This is the safe default.",
143
+ "Path/line thresholds only allowed this attempt; they never imply safety.",
144
+ `Previous accepted ReviewRound: ${previousRound.id}@${record.previousBaseCommit}`,
145
+ `Previous acceptance summary: ${compact(previousRound.summary ?? previousRound.report ?? "")}`,
146
+ ...(round.taskCandidate?.projects.map((project) => {
147
+ const previous = previousRound.taskCandidate?.projects
148
+ .find((entry) => entry.projectId === project.projectId)?.commit;
149
+ return previous === project.commit
150
+ ? `Exact boundary for ${project.projectId}: unchanged at ${project.commit}`
151
+ : `Exact diff for ${project.projectId}: ${previous}..${project.commit}`;
152
+ }) ?? []),
153
+ `Changed files: ${record.changedFiles.join(", ") || "none"}`,
154
+ `Diff size: +${record.addedLines}/-${record.deletedLines} (digest ${record.diffDigest})`,
155
+ "Precise diff:",
156
+ ...Object.entries(diffByProject).flatMap(([projectId, diff]) => (diff.trim().length === 0
157
+ ? []
158
+ : [`--- ${projectId} ---`, diff])),
159
+ ...(previousEvidenceReferences(previousRound).length === 0
160
+ ? []
161
+ : [
162
+ "Previous evidence references:",
163
+ ...previousEvidenceReferences(previousRound).map((entry) => `- ${entry}`)
164
+ ]),
165
+ ledgerContext,
166
+ "Report JSON with deltaDisposition (one of the three values above) and deltaReasoning",
167
+ "(the explicit proof or the reason a full Review is required). Findings, checks, and",
168
+ "evidence use the same fields as a full Review."
169
+ ];
170
+ return lines.join("\n");
171
+ }
172
+ function compact(text, limit = 600) {
173
+ const flattened = text.replace(/\s+/gu, " ").trim();
174
+ return flattened.length <= limit ? flattened : `${flattened.slice(0, limit)}...`;
175
+ }
176
+ function digestDiff(diffByProject) {
177
+ const canonical = JSON.stringify(Object.entries(diffByProject)
178
+ .sort(([left], [right]) => left.localeCompare(right))
179
+ .map(([projectId, diff]) => [projectId, diff]));
180
+ return createHash("sha256").update(canonical).digest("hex");
181
+ }
182
+ /**
183
+ * Extracts path-like evidence references from the previous Round's report.
184
+ * Free-form evidence that is not a path is still passed to the Reviewer in
185
+ * the dispatch context; only path-like entries drive the deterministic gate.
186
+ */
187
+ function extractEvidencePaths(round) {
188
+ return [...new Set(extractEvidenceFromReportJson(round.report ?? ""))]
189
+ .filter((entry) => isPathLike(entry));
190
+ }
191
+ /** All evidence references from the previous Round's JSON report. */
192
+ function previousEvidenceReferences(round) {
193
+ return extractEvidenceFromReportJson(round.report ?? "");
194
+ }
195
+ function extractEvidenceFromReportJson(report) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(report);
199
+ }
200
+ catch {
201
+ return [];
202
+ }
203
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
204
+ return [];
205
+ const evidence = parsed.evidence;
206
+ if (!Array.isArray(evidence))
207
+ return [];
208
+ return evidence
209
+ .filter((entry) => typeof entry === "string")
210
+ .map((entry) => entry.trim());
211
+ }
212
+ function isPathLike(value) {
213
+ if (value.length === 0 || value.length > 512)
214
+ return false;
215
+ if (/\s/u.test(value))
216
+ return false;
217
+ if (value.includes("://"))
218
+ return false;
219
+ // A repo-relative path: at least one slash or a known file extension, no
220
+ // shell/argument metacharacters.
221
+ if (/[;&|`$(){}!<>]/u.test(value))
222
+ return false;
223
+ return value.includes("/") || /\.[A-Za-z0-9]{1,12}$/u.test(value);
224
+ }
225
+ function pathEvidenceMatches(evidencePath, changedFile) {
226
+ const normalizedEvidence = evidencePath.replace(/^\.\//u, "");
227
+ const normalizedFile = changedFile.replace(/^\.\//u, "");
228
+ if (normalizedEvidence === normalizedFile)
229
+ return true;
230
+ // A directory-level evidence reference covers every file under it.
231
+ return normalizedFile.startsWith(`${normalizedEvidence}/`);
232
+ }
@@ -6,6 +6,11 @@ export const REVIEW_TRIGGERS = ["always", "leader", "final"];
6
6
  * Task completion closed on undispositioned open P1/P2 findings.
7
7
  */
8
8
  export const REVIEW_FINDING_LEDGER_MODES = ["shadow", "enforce"];
9
+ /** Issue 07: delta-recheck is opt-in per Project review policy. */
10
+ export const REVIEW_DELTA_RECHECK_MODES = ["enabled", "disabled"];
11
+ /** Issue 07: conservative defaults for whether a delta attempt is allowed. */
12
+ export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES = 200;
13
+ export const DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES = 5;
9
14
  /** The Reviewer Role seeded in a new Home by `yui setup`. */
10
15
  export const DEFAULT_REVIEWER_ROLE = "reviewer";
11
16
  export function validateReviewConfig(config) {
@@ -17,5 +22,31 @@ export function validateReviewConfig(config) {
17
22
  && !REVIEW_FINDING_LEDGER_MODES.includes(config.findingLedger)) {
18
23
  throw new Error(`Review finding ledger mode is invalid: ${String(config.findingLedger)}.`);
19
24
  }
25
+ if (config.deltaRecheck !== undefined
26
+ && !REVIEW_DELTA_RECHECK_MODES.includes(config.deltaRecheck)) {
27
+ throw new Error(`Review delta-recheck mode is invalid: ${String(config.deltaRecheck)}.`);
28
+ }
29
+ if (config.deltaRecheckMaxChangedLines !== undefined
30
+ && (!Number.isInteger(config.deltaRecheckMaxChangedLines)
31
+ || config.deltaRecheckMaxChangedLines < 1)) {
32
+ throw new Error("Review delta-recheck max changed lines must be a positive integer.");
33
+ }
34
+ if (config.deltaRecheckMaxChangedFiles !== undefined
35
+ && (!Number.isInteger(config.deltaRecheckMaxChangedFiles)
36
+ || config.deltaRecheckMaxChangedFiles < 1)) {
37
+ throw new Error("Review delta-recheck max changed files must be a positive integer.");
38
+ }
20
39
  return config;
21
40
  }
41
+ /** Issue 07: resolves the delta-recheck policy; absent config defaults to disabled. */
42
+ export function deltaRecheckEnabled(config) {
43
+ return config?.deltaRecheck === "enabled";
44
+ }
45
+ /** Issue 07: resolves the line threshold with its conservative default. */
46
+ export function deltaRecheckMaxChangedLines(config) {
47
+ return config?.deltaRecheckMaxChangedLines ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_LINES;
48
+ }
49
+ /** Issue 07: resolves the file threshold with its conservative default. */
50
+ export function deltaRecheckMaxChangedFiles(config) {
51
+ return config?.deltaRecheckMaxChangedFiles ?? DEFAULT_DELTA_RECHECK_MAX_CHANGED_FILES;
52
+ }
@@ -252,8 +252,12 @@ export function reconcileReviewFindingsAfterReview(store, taskId, roundId, now)
252
252
  }
253
253
  /** True when a semantic Review could not be reconciled into the ledger. */
254
254
  export function reviewFindingLedgerWriteFailed(store, taskId) {
255
+ return reviewFindingLedgerWriteFailedFromEvents(store.listEvents(taskId));
256
+ }
257
+ /** Pure event-fold variant of {@link reviewFindingLedgerWriteFailed}. */
258
+ export function reviewFindingLedgerWriteFailedFromEvents(events) {
255
259
  const latestByRound = new Map();
256
- for (const event of store.listEvents(taskId)) {
260
+ for (const event of events) {
257
261
  if (event.type !== REVIEW_FINDINGS_RECONCILE_FAILED_EVENT
258
262
  && event.type !== "review.findings-reconciled") {
259
263
  continue;
@@ -39,6 +39,35 @@ export function createTaskReviewRound(id, taskId, workItemId, candidateId, revie
39
39
  createdAt: now.toISOString()
40
40
  });
41
41
  }
42
+ /**
43
+ * Issue 07: creates a delta-recheck Task-final Round. The Round still targets
44
+ * the new frozen head and still requires a fresh Reviewer disposition; the
45
+ * delta record only binds the recheck to the previous acceptance and the exact
46
+ * diff so the Reviewer can prove equivalence instead of reloading every
47
+ * first-round evidence.
48
+ */
49
+ export function createTaskDeltaReviewRound(id, taskId, workItemId, candidateId, reviewerRoleName, requestedBy, taskCandidate, deltaRecheck, now, taskFinalReviewContract, executionGroup) {
50
+ const candidate = validateTaskReviewCandidate(taskCandidate);
51
+ return validateReviewRound({
52
+ schemaVersion: 4,
53
+ id: requireIdentity(id, "ReviewRound id"),
54
+ taskId: requireIdentity(taskId, "Task id"),
55
+ workItemId: requireIdentity(workItemId, "Work Item id"),
56
+ candidateId: requireIdentity(candidateId, "Candidate id"),
57
+ reviewerRoleName: requireIdentity(reviewerRoleName, "Reviewer Role"),
58
+ reviewBaseCommit: candidate.projects[0].commit,
59
+ scope: "task",
60
+ taskCandidate: candidate,
61
+ ...(taskFinalReviewContract === undefined
62
+ ? {}
63
+ : { taskFinalReviewContract }),
64
+ deltaRecheck: validateDeltaRecheckRecord(deltaRecheck),
65
+ requestedBy: validateReviewRequestSource(requestedBy),
66
+ status: "pending",
67
+ ...(executionGroup === undefined ? {} : { executionGroup }),
68
+ createdAt: now.toISOString()
69
+ });
70
+ }
42
71
  export function attachReviewRoundWorkspace(round, workspace) {
43
72
  validateReviewRound(round);
44
73
  validateReviewWorkspace(round, workspace);
@@ -72,7 +101,7 @@ export function finishReviewRound(round, status, summary, now, result = {}) {
72
101
  if (round.status !== "pending" && round.status !== "running") {
73
102
  throw new Error(`ReviewRound is already terminal: ${round.id}.`);
74
103
  }
75
- return validateReviewRound({
104
+ const terminal = validateReviewRound({
76
105
  ...round,
77
106
  status,
78
107
  summary: requireText(summary, "Review summary"),
@@ -83,6 +112,29 @@ export function finishReviewRound(round, status, summary, now, result = {}) {
83
112
  : { evidenceCommit: requireCommit(result.evidenceCommit, "Review evidence commit") }),
84
113
  endedAt: now.toISOString()
85
114
  });
115
+ if (round.deltaRecheck === undefined) {
116
+ if (result.deltaDisposition !== undefined || result.deltaReasoning !== undefined) {
117
+ throw new Error(`Only a delta-recheck ReviewRound can carry a delta disposition: ${round.id}.`);
118
+ }
119
+ return terminal;
120
+ }
121
+ if (status !== "completed") {
122
+ // A failed delta Round is an infra attempt; it records no disposition and
123
+ // the candidate stays unaccepted.
124
+ return terminal;
125
+ }
126
+ // Fail closed: a completed delta Round without an explicit, valid
127
+ // disposition escalates to a full Review. Uncertainty never accepts.
128
+ const disposition = result.deltaDisposition ?? "requires-full-review";
129
+ const reasoning = result.deltaReasoning ?? result.report ?? summary;
130
+ return validateReviewRound({
131
+ ...terminal,
132
+ deltaRecheck: validateDeltaRecheckRecord({
133
+ ...round.deltaRecheck,
134
+ disposition,
135
+ reasoning: requireText(reasoning, "Delta recheck reasoning")
136
+ })
137
+ });
86
138
  }
87
139
  /**
88
140
  * Issue 06: retry a failed Task-final execution attempt under the same semantic
@@ -114,6 +166,22 @@ export function retryTaskReviewRound(round) {
114
166
  ...(round.taskFinalReviewContract === undefined
115
167
  ? {}
116
168
  : { taskFinalReviewContract: round.taskFinalReviewContract }),
169
+ ...(round.deltaRecheck === undefined
170
+ ? {}
171
+ // A retried delta Round is still the same semantic recheck: the
172
+ // disposition is terminal evidence and must not be carried into the
173
+ // fresh attempt, so only the immutable lineage is preserved.
174
+ : {
175
+ deltaRecheck: validateDeltaRecheckRecord({
176
+ schemaVersion: 1,
177
+ previousReviewRoundId: round.deltaRecheck.previousReviewRoundId,
178
+ previousBaseCommit: round.deltaRecheck.previousBaseCommit,
179
+ diffDigest: round.deltaRecheck.diffDigest,
180
+ changedFiles: round.deltaRecheck.changedFiles,
181
+ addedLines: round.deltaRecheck.addedLines,
182
+ deletedLines: round.deltaRecheck.deletedLines
183
+ })
184
+ }),
117
185
  // Keep the historical attempt Group and Lane addressable from AgentRun
118
186
  // history while resetting the Lane for another dispatch attempt.
119
187
  ...(retryExecutionGroup === undefined ? {} : { executionGroup: retryExecutionGroup }),
@@ -154,12 +222,18 @@ export function parseReviewYieldReport(value) {
154
222
  const checks = extractChecks(record.checks);
155
223
  const findings = extractFindings(record.findings);
156
224
  const evidence = extractEvidence(record.evidence);
225
+ const deltaDisposition = extractDeltaDisposition(record.deltaDisposition);
157
226
  return {
158
227
  summary,
159
228
  report,
160
229
  checks,
161
230
  ...(findings.length === 0 ? {} : { findings }),
162
231
  ...(evidence.length === 0 ? {} : { evidence }),
232
+ ...(deltaDisposition === undefined ? {} : { deltaDisposition }),
233
+ ...(typeof record.deltaReasoning !== "string"
234
+ || record.deltaReasoning.trim().length === 0
235
+ ? {}
236
+ : { deltaReasoning: requireText(record.deltaReasoning, "Delta recheck reasoning") }),
163
237
  ...(typeof record.evidenceCommit !== "string"
164
238
  ? {}
165
239
  : {
@@ -167,6 +241,14 @@ export function parseReviewYieldReport(value) {
167
241
  })
168
242
  };
169
243
  }
244
+ function extractDeltaDisposition(value) {
245
+ if (value !== "equivalent-and-accepted"
246
+ && value !== "finding"
247
+ && value !== "requires-full-review") {
248
+ return undefined;
249
+ }
250
+ return value;
251
+ }
170
252
  function extractFindings(value) {
171
253
  if (!Array.isArray(value))
172
254
  return [];
@@ -300,6 +382,12 @@ export function validateReviewRound(round) {
300
382
  if (!["pending", "running", "completed", "failed"].includes(round.status)) {
301
383
  throw new Error(`ReviewRound status is invalid: ${String(round.status)}.`);
302
384
  }
385
+ if (round.deltaRecheck !== undefined) {
386
+ if (scope !== "task") {
387
+ throw new Error(`Only a Task-final ReviewRound can be a delta-recheck: ${round.id}.`);
388
+ }
389
+ validateDeltaRecheckRecord(round.deltaRecheck);
390
+ }
303
391
  if (round.reviewerRunId !== undefined) {
304
392
  validateTaskRecordReference({
305
393
  taskId: round.taskId,
@@ -345,8 +433,75 @@ export function validateReviewRound(round) {
345
433
  }
346
434
  requireTimestamp(round.workspaceDisposition.recordedAt, "Review workspace disposition time");
347
435
  }
436
+ if (round.deltaRecheck?.disposition !== undefined) {
437
+ if (!terminal) {
438
+ throw new Error("An active delta-recheck ReviewRound cannot have a disposition.");
439
+ }
440
+ if (round.deltaRecheck.reasoning === undefined
441
+ || round.deltaRecheck.reasoning.trim().length === 0) {
442
+ throw new Error("A terminal delta-recheck ReviewRound requires reasoning.");
443
+ }
444
+ if (round.deltaRecheck.escalatedToReviewRoundId !== undefined
445
+ && round.deltaRecheck.disposition !== "requires-full-review") {
446
+ throw new Error("Only a requires-full-review delta-recheck can record an escalation.");
447
+ }
448
+ }
348
449
  return round;
349
450
  }
451
+ /** Validates a delta-recheck record's immutable identity and terminal fields. */
452
+ export function validateDeltaRecheckRecord(record) {
453
+ if (record.schemaVersion !== 1) {
454
+ throw new Error("Delta recheck record must use schemaVersion 1.");
455
+ }
456
+ requireIdentity(record.previousReviewRoundId, "Delta recheck previous ReviewRound id");
457
+ requireCommit(record.previousBaseCommit, "Delta recheck previous base commit");
458
+ if (!/^[a-f0-9]{64}$/u.test(record.diffDigest)) {
459
+ throw new Error("Delta recheck diff digest is invalid.");
460
+ }
461
+ if (!Array.isArray(record.changedFiles)
462
+ || record.changedFiles.some((file) => typeof file !== "string" || file.trim().length === 0)) {
463
+ throw new Error("Delta recheck changed files are invalid.");
464
+ }
465
+ if (!Number.isInteger(record.addedLines) || record.addedLines < 0
466
+ || !Number.isInteger(record.deletedLines) || record.deletedLines < 0) {
467
+ throw new Error("Delta recheck line counts are invalid.");
468
+ }
469
+ if (record.disposition !== undefined
470
+ && record.disposition !== "equivalent-and-accepted"
471
+ && record.disposition !== "finding"
472
+ && record.disposition !== "requires-full-review") {
473
+ throw new Error(`Delta recheck disposition is invalid: ${String(record.disposition)}.`);
474
+ }
475
+ if (record.reasoning !== undefined) {
476
+ requireText(record.reasoning, "Delta recheck reasoning");
477
+ }
478
+ if (record.escalatedToReviewRoundId !== undefined) {
479
+ requireIdentity(record.escalatedToReviewRoundId, "Delta recheck escalation ReviewRound id");
480
+ }
481
+ return record;
482
+ }
483
+ /** True when this Round is an Issue 07 delta-recheck. */
484
+ export function isDeltaRecheckRound(round) {
485
+ return round.deltaRecheck !== undefined;
486
+ }
487
+ /** True only when a delta Round explicitly accepted the new head. */
488
+ export function deltaRecheckAccepted(round) {
489
+ return round.deltaRecheck?.disposition === "equivalent-and-accepted";
490
+ }
491
+ /** True when a delta Round escalated to a full Review. */
492
+ export function deltaRecheckEscalated(round) {
493
+ return round.deltaRecheck?.disposition === "requires-full-review";
494
+ }
495
+ /**
496
+ * True when a completed delta Round does NOT accept the new head. A `finding`
497
+ * or `requires-full-review` disposition must keep the completion gate closed.
498
+ */
499
+ export function deltaRecheckBlocksAcceptance(round) {
500
+ return round.deltaRecheck !== undefined
501
+ && round.status === "completed"
502
+ && round.deltaRecheck.disposition !== undefined
503
+ && round.deltaRecheck.disposition !== "equivalent-and-accepted";
504
+ }
350
505
  function validateReviewExecutionGroup(group, round) {
351
506
  validateExecutionGroup(group);
352
507
  if (group.taskId !== round.taskId || group.purpose !== "review") {
@@ -2,9 +2,16 @@ import { requireIdentity, requireText, requireTimestamp } from "../domain/valida
2
2
  export const PROVIDER_RETRY_BASE_DELAY_MS = 1_000;
3
3
  export const PROVIDER_RETRY_MAX_DELAY_MS = 60_000;
4
4
  /**
5
- * Bounded exponential backoff. The delay is uncapped in attempt count (Issue
6
- * 04 retries transient failures indefinitely with backoff) but capped in
7
- * interval so a hot loop can never occupy the control plane.
5
+ * Default total budget for one in-place retry lineage. The window starts at
6
+ * the first classified failure and bounds the total wall-clock time the Run
7
+ * may stay in `provider-retrying`; once it elapses the Run terminalizes with
8
+ * one structured failure instead of looping.
9
+ */
10
+ export const PROVIDER_RETRY_MAX_WINDOW_MS = 600_000;
11
+ /**
12
+ * Bounded exponential backoff. The delay is capped in interval so a hot loop
13
+ * can never occupy the control plane; the total retry lineage is bounded by
14
+ * {@link PROVIDER_RETRY_MAX_WINDOW_MS} (see {@link providerRetryBudgetExhausted}).
8
15
  */
9
16
  export function nextProviderRetryDelayMs(attempt) {
10
17
  if (!Number.isSafeInteger(attempt) || attempt < 1) {
@@ -13,6 +20,17 @@ export function nextProviderRetryDelayMs(attempt) {
13
20
  const exponent = Math.min(attempt - 1, 10);
14
21
  return Math.min(PROVIDER_RETRY_MAX_DELAY_MS, PROVIDER_RETRY_BASE_DELAY_MS * (2 ** exponent));
15
22
  }
23
+ /**
24
+ * True when the retry lineage has used its total wall-clock budget. The
25
+ * budget is measured from the first classified failure, so repeated failures
26
+ * never extend it.
27
+ */
28
+ export function providerRetryBudgetExhausted(value, now, maxWindowMs = PROVIDER_RETRY_MAX_WINDOW_MS) {
29
+ if (!Number.isSafeInteger(maxWindowMs) || maxWindowMs <= 0) {
30
+ throw new Error(`Provider retry max window must be a positive integer: ${String(maxWindowMs)}.`);
31
+ }
32
+ return Date.parse(value.firstFailureAt) + maxWindowMs <= now.getTime();
33
+ }
16
34
  export function validateAgentRunProviderRetry(value) {
17
35
  if (value.schemaVersion !== 1) {
18
36
  throw new Error("Agent run providerRetry must use schemaVersion 1.");
@@ -1,65 +1,18 @@
1
- import { supportedAgentAdapterIds } from "../agent/adapterCatalog.js";
2
- function parseAdapters(value) {
3
- if (value === undefined)
4
- return [];
5
- const supported = supportedAgentAdapterIds();
6
- const supportedSet = new Set(supported);
7
- const adapters = [];
8
- for (const raw of value.split(",")) {
9
- const token = raw.trim().toLowerCase();
10
- if (token === "")
11
- continue;
12
- if (token === "all") {
13
- for (const adapter of supported) {
14
- if (!adapters.includes(adapter))
15
- adapters.push(adapter);
16
- }
17
- continue;
18
- }
19
- if (!/^[a-z0-9][a-z0-9._-]*$/u.test(token)) {
20
- throw new Error(`Invalid Provider retry adapter: ${token}.`);
21
- }
22
- if (!supportedSet.has(token)) {
23
- throw new Error(`Unknown Provider retry adapter: ${token}.`);
24
- }
25
- if (!adapters.includes(token)) {
26
- adapters.push(token);
27
- }
28
- }
29
- return adapters;
30
- }
1
+ import { resolveProviderRetryAdapters, resolveProviderRetryMaxWindowMs, resolveProviderRetryMode, resolveYieldReceiptReplay } from "../config/yuiConfig.js";
31
2
  /**
32
- * Resolves the Issue 04 flags from the process environment.
33
- *
34
- * - `YUI_PROVIDER_RETRY_IN_PLACE` — comma-separated adapter ids (`claude`,
35
- * `codex`, `all`). Unset/empty disables the feature entirely.
36
- * - `YUI_PROVIDER_RETRY_MODE` — `shadow` (default when adapters are listed)
37
- * records classification and "would retry" facts without changing behavior;
38
- * `enforce` keeps the Run active and retries in place.
39
- * - `YUI_YIELD_RECEIPT_REPLAY` — `0` disables receipt replay; default `1`.
3
+ * Resolves the retry flags from the durable Yui config. Homes without the
4
+ * fields get the safe defaults: enforce mode, all supported adapters, receipt
5
+ * replay on, 10-minute budget.
40
6
  */
41
- export function providerRetryConfig(environment = process.env) {
42
- const adapters = parseAdapters(environment.YUI_PROVIDER_RETRY_IN_PLACE);
43
- const modeValue = environment.YUI_PROVIDER_RETRY_MODE?.trim().toLowerCase();
44
- let mode;
45
- if (adapters.length === 0) {
46
- mode = "off";
47
- }
48
- else if (modeValue === "enforce") {
49
- mode = "enforce";
50
- }
51
- else if (modeValue === undefined || modeValue === "" || modeValue === "shadow") {
52
- mode = "shadow";
53
- }
54
- else {
55
- throw new Error(`Unknown Provider retry mode: ${modeValue}.`);
56
- }
57
- const replayValue = environment.YUI_YIELD_RECEIPT_REPLAY?.trim();
58
- const yieldReceiptReplay = replayValue === undefined || replayValue === "" || replayValue === "1";
59
- if (!["0", "1"].includes(replayValue ?? "1")) {
60
- throw new Error(`YUI_YIELD_RECEIPT_REPLAY must be 0 or 1: ${replayValue}.`);
61
- }
62
- return { mode, adapters, yieldReceiptReplay };
7
+ export function providerRetryConfig(config) {
8
+ const mode = resolveProviderRetryMode(config.providerRetryMode);
9
+ const adapters = resolveProviderRetryAdapters(config.providerRetryAdapters);
10
+ return {
11
+ mode: adapters.length === 0 ? "off" : mode,
12
+ adapters,
13
+ yieldReceiptReplay: resolveYieldReceiptReplay(config.yieldReceiptReplay),
14
+ maxWindowMs: resolveProviderRetryMaxWindowMs(config.providerRetryMaxWindowMs)
15
+ };
63
16
  }
64
17
  /** Whether the adapter has in-place retry enabled in the given mode. */
65
18
  export function providerRetryEnabledForAdapter(config, adapterId, mode) {