codex-workflow-v2 2.0.0-alpha.7.1 → 2.0.0-alpha.7.2.1
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/README.md +12 -2
- package/dist/src/alpha6/plan-risk.d.ts +1 -0
- package/dist/src/alpha6/plan-risk.js +2 -2
- package/dist/src/alpha6/plan-risk.js.map +1 -1
- package/dist/src/alpha6/remediation.d.ts +19 -2
- package/dist/src/alpha6/remediation.js +473 -11
- package/dist/src/alpha6/remediation.js.map +1 -1
- package/dist/src/alpha7/corrective-recovery.d.ts +26 -0
- package/dist/src/alpha7/corrective-recovery.js +593 -0
- package/dist/src/alpha7/corrective-recovery.js.map +1 -0
- package/dist/src/cli.js +8 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +44 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/workflow.d.ts +2 -0
- package/dist/src/workflow.js +111 -11
- package/dist/src/workflow.js.map +1 -1
- package/docs/alpha7.2-corrective-context-refresh-brief.md +484 -0
- package/docs/alpha7.2.1-remediation-recovery-brief.md +86 -0
- package/package.json +1 -1
- package/schemas/corrective-decision-recovery-event.schema.json +71 -0
- package/schemas/remediation-mode-recovery-event.schema.json +42 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { WorkflowError } from '../errors.js';
|
|
4
4
|
import { createUlid } from '../ulid.js';
|
|
5
|
-
import { assertHash64 } from './store-sidecars.js';
|
|
5
|
+
import { assertHash64, canonicalJsonStringify, sha256Hex } from './store-sidecars.js';
|
|
6
|
+
import { readTaskC1Posture } from './handoff.js';
|
|
6
7
|
import { buildCurrentStrictStepReviewCycle, readReviewerAttestations, readStepReviewEvents, } from './review.js';
|
|
7
8
|
export const REMEDIATION_EVENTS_SIDECAR = 'remediation-events.jsonl';
|
|
8
9
|
export const CORRECTIVE_DECISIONS_SIDECAR = 'corrective-decisions.jsonl';
|
|
10
|
+
export const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = 'corrective-decision-recoveries.jsonl';
|
|
11
|
+
export const REMEDIATION_MODE_RECOVERIES_SIDECAR = 'remediation-mode-recoveries.jsonl';
|
|
12
|
+
const REMEDIATION_MODE_RECOVERY_LOCK = '.remediation-mode-recovery.lock';
|
|
9
13
|
const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/;
|
|
10
14
|
const REMEDIATION_FAILURE_KINDS = new Set([
|
|
11
15
|
'checks-failed',
|
|
@@ -65,15 +69,35 @@ export function assertCorrectiveAuditorIndependence(store, task, stepId, auditor
|
|
|
65
69
|
function validateRemediationHistory(store, task) {
|
|
66
70
|
const remediationEvents = readRawRemediationEvents(store, task);
|
|
67
71
|
const correctiveDecisionEvents = readRawCorrectiveDecisionEvents(store, task);
|
|
72
|
+
const modeRecoveries = readRemediationModeRecoveryEvents(store, task);
|
|
68
73
|
validateRemediationAttemptOrdinals(remediationEvents, task);
|
|
69
74
|
validateRemediationReviewReferences(store, task, remediationEvents);
|
|
70
75
|
validateCorrectiveDecisionCoverage(task, correctiveDecisionEvents, remediationEvents);
|
|
71
|
-
|
|
76
|
+
const recoveredEventIds = validateRemediationModeRecoveries(store, task, remediationEvents, correctiveDecisionEvents, modeRecoveries);
|
|
77
|
+
validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisionEvents, recoveredEventIds);
|
|
72
78
|
return {
|
|
73
79
|
remediationEvents,
|
|
74
80
|
correctiveDecisionEvents,
|
|
75
81
|
};
|
|
76
82
|
}
|
|
83
|
+
export function readRemediationModeRecoveryEvents(store, task) {
|
|
84
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
85
|
+
const file = path.join(root, REMEDIATION_MODE_RECOVERIES_SIDECAR);
|
|
86
|
+
if (!existsSync(file))
|
|
87
|
+
return [];
|
|
88
|
+
return store.readSidecarEvents(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
|
|
89
|
+
validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, task, context.file, context.line),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function readRawCorrectiveDecisionRecoveries(store, task) {
|
|
93
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
94
|
+
const file = path.join(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR);
|
|
95
|
+
if (!existsSync(file))
|
|
96
|
+
return [];
|
|
97
|
+
return store.readSidecarEvents(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR, {
|
|
98
|
+
validateEvent: (event, context) => validateStoredCorrectiveDecisionRecoveryReference(event, task, context.file, context.line),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
77
101
|
function readRawRemediationEvents(store, task) {
|
|
78
102
|
const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR);
|
|
79
103
|
if (!existsSync(sidecarFile))
|
|
@@ -91,6 +115,7 @@ function readRawCorrectiveDecisionEvents(store, task) {
|
|
|
91
115
|
});
|
|
92
116
|
}
|
|
93
117
|
export function appendRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now) {
|
|
118
|
+
const recoveredDecisionEventId = findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId);
|
|
94
119
|
const existing = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
95
120
|
const matching = existing.filter((event) => remediationEventMatchesFailure(event, failureKind, failureEvidence));
|
|
96
121
|
if (matching.length > 1) {
|
|
@@ -104,7 +129,9 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
|
|
|
104
129
|
if (matching.length === 1) {
|
|
105
130
|
const reused = matching[0];
|
|
106
131
|
const priorEvents = existing.filter((event) => event.attemptOrdinal < reused.attemptOrdinal);
|
|
107
|
-
const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, priorEvents, readCorrectiveDecisionEvents(store, task)
|
|
132
|
+
const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, priorEvents, readCorrectiveDecisionEvents(store, task), {
|
|
133
|
+
recoveredDecisionEventId,
|
|
134
|
+
});
|
|
108
135
|
if (reused.attemptOrdinal !== gate.nextAttemptOrdinal || reused.mode !== gate.mode || reused.guardedCategory !== gate.guardedCategory) {
|
|
109
136
|
throw new WorkflowError('STATE_CORRUPT', 'Existing remediation event conflicts with the current guarded retry binding.', {
|
|
110
137
|
taskId: task.id,
|
|
@@ -118,7 +145,9 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
|
|
|
118
145
|
}
|
|
119
146
|
return reused;
|
|
120
147
|
}
|
|
121
|
-
const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, existing, readCorrectiveDecisionEvents(store, task)
|
|
148
|
+
const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, existing, readCorrectiveDecisionEvents(store, task), {
|
|
149
|
+
recoveredDecisionEventId,
|
|
150
|
+
});
|
|
122
151
|
return store.appendSidecarEvent(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR, {
|
|
123
152
|
eventId: `RME-${createUlid(now.getTime())}`,
|
|
124
153
|
recordedAt: now.toISOString(),
|
|
@@ -133,6 +162,290 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
|
|
|
133
162
|
validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
|
|
134
163
|
});
|
|
135
164
|
}
|
|
165
|
+
function findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId) {
|
|
166
|
+
const remediations = readRawRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
167
|
+
if (remediations.length !== 2)
|
|
168
|
+
return null;
|
|
169
|
+
const decisions = readRawCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId
|
|
170
|
+
&& event.triggeringAttemptCount === 3
|
|
171
|
+
&& event.decision === 'continue-fix'
|
|
172
|
+
&& event.planHash !== task.planHash);
|
|
173
|
+
if (decisions.length !== 1)
|
|
174
|
+
return null;
|
|
175
|
+
const recoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) => event.stepId === stepId
|
|
176
|
+
&& event.triggeringAttemptCount === 3
|
|
177
|
+
&& event.correctiveDecisionEventId === decisions[0].eventId
|
|
178
|
+
&& event.correctiveDecisionEventHash === decisions[0].eventHash
|
|
179
|
+
&& event.reboundPlanHash === task.planHash);
|
|
180
|
+
if (recoveries.length > 1) {
|
|
181
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple corrective recoveries match one remediation attempt.', {
|
|
182
|
+
taskId: task.id,
|
|
183
|
+
stepId,
|
|
184
|
+
recoveryEventIds: recoveries.map((event) => event.eventId),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return recoveries[0]?.correctiveDecisionEventId ?? null;
|
|
188
|
+
}
|
|
189
|
+
export function findRemediationModeRecoveryCandidate(store, task) {
|
|
190
|
+
const raw = readRawRemediationEvents(store, task);
|
|
191
|
+
const candidates = raw.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
|
|
192
|
+
if (candidates.length === 0)
|
|
193
|
+
return null;
|
|
194
|
+
if (candidates.length !== 1) {
|
|
195
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple malformed remediation modes are not recoverable.', {
|
|
196
|
+
taskId: task.id,
|
|
197
|
+
eventIds: candidates.map((event) => event.eventId),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const posture = readTaskC1Posture(store, task);
|
|
201
|
+
const lease = readWriterLeaseForModeRecovery(store, task);
|
|
202
|
+
const actor = posture.state === 'claimed' ? posture.claimant : lease?.owner ?? 'worker';
|
|
203
|
+
return assessRemediationModeRecovery(store, task, candidates[0].stepId, actor, lease?.token ?? null);
|
|
204
|
+
}
|
|
205
|
+
export function assessRemediationModeRecovery(store, task, stepId, actor, writerToken) {
|
|
206
|
+
const normalizedActor = actor.trim();
|
|
207
|
+
if (!normalizedActor)
|
|
208
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Remediation mode recovery actor is required.');
|
|
209
|
+
const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
|
|
210
|
+
if (!step)
|
|
211
|
+
throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
|
|
212
|
+
if (task.status !== 'needs_fix' || step.status !== 'failed') {
|
|
213
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the exact failed Task/Step posture.', {
|
|
214
|
+
taskId: task.id,
|
|
215
|
+
taskStatus: task.status,
|
|
216
|
+
stepId,
|
|
217
|
+
stepStatus: step.status,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (!task.planHash)
|
|
221
|
+
throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing its Plan hash.`);
|
|
222
|
+
const remediations = readRawRemediationEvents(store, task);
|
|
223
|
+
const decisions = readRawCorrectiveDecisionEvents(store, task);
|
|
224
|
+
validateRemediationAttemptOrdinals(remediations, task);
|
|
225
|
+
validateRemediationReviewReferences(store, task, remediations);
|
|
226
|
+
validateCorrectiveDecisionCoverage(task, decisions, remediations);
|
|
227
|
+
const malformed = remediations.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
|
|
228
|
+
if (malformed.length !== 1 || malformed[0].stepId !== stepId || malformed[0].attemptOrdinal !== 3) {
|
|
229
|
+
throw new WorkflowError('STATE_CORRUPT', 'State does not contain the one exact alpha.7.2 remediation mode omission.', {
|
|
230
|
+
taskId: task.id,
|
|
231
|
+
stepId,
|
|
232
|
+
malformedEventIds: malformed.map((event) => event.eventId),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const stepRemediations = remediations.filter((event) => event.stepId === stepId);
|
|
236
|
+
if (stepRemediations.length !== 3
|
|
237
|
+
|| stepRemediations[0].mode !== 'ordinary'
|
|
238
|
+
|| stepRemediations[1].mode !== 'ordinary'
|
|
239
|
+
|| stepRemediations[2].eventId !== malformed[0].eventId) {
|
|
240
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires exactly two ordinary attempts and one malformed third attempt.', {
|
|
241
|
+
taskId: task.id,
|
|
242
|
+
stepId,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const decisionMatches = decisions.filter((event) => event.stepId === stepId
|
|
246
|
+
&& event.triggeringAttemptCount === 3
|
|
247
|
+
&& event.decision === 'continue-fix'
|
|
248
|
+
&& sameStringArray(event.coveredEventIds, stepRemediations.slice(0, 2).map((event) => event.eventId)));
|
|
249
|
+
if (decisionMatches.length !== 1) {
|
|
250
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact continue-fix decision.', {
|
|
251
|
+
taskId: task.id,
|
|
252
|
+
stepId,
|
|
253
|
+
decisionEventIds: decisionMatches.map((event) => event.eventId),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const decision = decisionMatches[0];
|
|
257
|
+
const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) => event.stepId === stepId
|
|
258
|
+
&& event.triggeringAttemptCount === 3
|
|
259
|
+
&& event.correctiveDecisionEventId === decision.eventId
|
|
260
|
+
&& event.correctiveDecisionEventHash === decision.eventHash
|
|
261
|
+
&& event.reboundPlanHash === task.planHash);
|
|
262
|
+
if (correctiveRecoveries.length !== 1) {
|
|
263
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact corrective-decision recovery.', {
|
|
264
|
+
taskId: task.id,
|
|
265
|
+
stepId,
|
|
266
|
+
recoveryEventIds: correctiveRecoveries.map((event) => event.eventId),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const correctiveRecovery = correctiveRecoveries[0];
|
|
270
|
+
const authority = requireRemediationModeRecoveryAuthority(store, task, normalizedActor, writerToken);
|
|
271
|
+
const existing = readRemediationModeRecoveryEvents(store, task).filter((event) => event.remediationEventId === malformed[0].eventId);
|
|
272
|
+
if (existing.length > 1) {
|
|
273
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation mode recoveries target one event.', {
|
|
274
|
+
taskId: task.id,
|
|
275
|
+
remediationEventId: malformed[0].eventId,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (existing[0]) {
|
|
279
|
+
validateRemediationModeRecoveryBinding(existing[0], malformed[0], decision, correctiveRecovery);
|
|
280
|
+
const retryMismatches = [
|
|
281
|
+
existing[0].taskRevision !== task.revision ? 'taskRevision' : null,
|
|
282
|
+
existing[0].planHash !== task.planHash ? 'planHash' : null,
|
|
283
|
+
existing[0].actor !== normalizedActor ? 'actor' : null,
|
|
284
|
+
existing[0].c1ClaimEventHash !== authority.c1ClaimEventHash ? 'c1ClaimEventHash' : null,
|
|
285
|
+
existing[0].writerLeaseTokenHash !== authority.writerLeaseTokenHash ? 'writerLeaseTokenHash' : null,
|
|
286
|
+
].filter((value) => value !== null);
|
|
287
|
+
if (retryMismatches.length > 0) {
|
|
288
|
+
throw new WorkflowError('STATE_CONFLICT', 'Existing remediation mode recovery conflicts with the requested retry binding.', {
|
|
289
|
+
taskId: task.id,
|
|
290
|
+
stepId,
|
|
291
|
+
recoveryEventId: existing[0].eventId,
|
|
292
|
+
mismatches: retryMismatches,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const evidenceFingerprint = sha256Hex(canonicalJsonStringify({
|
|
297
|
+
taskId: task.id,
|
|
298
|
+
taskRevision: task.revision,
|
|
299
|
+
stepId,
|
|
300
|
+
planHash: task.planHash,
|
|
301
|
+
remediationEventHash: malformed[0].eventHash,
|
|
302
|
+
correctiveDecisionEventHash: decision.eventHash,
|
|
303
|
+
correctiveDecisionRecoveryEventHash: correctiveRecovery.eventHash,
|
|
304
|
+
actor: normalizedActor,
|
|
305
|
+
c1ClaimEventHash: authority.c1ClaimEventHash,
|
|
306
|
+
writerLeaseTokenHash: authority.writerLeaseTokenHash,
|
|
307
|
+
}));
|
|
308
|
+
return {
|
|
309
|
+
task,
|
|
310
|
+
remediation: malformed[0],
|
|
311
|
+
decision,
|
|
312
|
+
correctiveRecovery,
|
|
313
|
+
existingRecovery: existing[0] ?? null,
|
|
314
|
+
actor: normalizedActor,
|
|
315
|
+
c1ClaimEventHash: authority.c1ClaimEventHash,
|
|
316
|
+
writerLeaseTokenHash: authority.writerLeaseTokenHash,
|
|
317
|
+
evidenceFingerprint,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export function appendRemediationModeRecovery(store, task, stepId, expectedRevision, actor, writerToken, now) {
|
|
321
|
+
if (task.revision !== expectedRevision) {
|
|
322
|
+
throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
|
|
323
|
+
expectedRevision,
|
|
324
|
+
actualRevision: task.revision,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const preflight = assessRemediationModeRecovery(store, task, stepId, actor, writerToken);
|
|
328
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
329
|
+
const lockFile = path.join(root, REMEDIATION_MODE_RECOVERY_LOCK);
|
|
330
|
+
let descriptor;
|
|
331
|
+
try {
|
|
332
|
+
descriptor = openSync(lockFile, 'wx', 0o600);
|
|
333
|
+
closeSync(descriptor);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (existsSync(lockFile)) {
|
|
337
|
+
throw new WorkflowError('LOCKED', `Remediation mode recovery for ${task.id} is already being serialized.`, {
|
|
338
|
+
taskId: task.id,
|
|
339
|
+
stepId,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const currentTask = store.readTask(task.projectId, task.id);
|
|
346
|
+
if (currentTask.revision !== expectedRevision) {
|
|
347
|
+
throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
|
|
348
|
+
expectedRevision,
|
|
349
|
+
actualRevision: currentTask.revision,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
let underLock;
|
|
353
|
+
try {
|
|
354
|
+
underLock = assessRemediationModeRecovery(store, currentTask, stepId, actor, writerToken);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
|
|
358
|
+
taskId: task.id,
|
|
359
|
+
stepId,
|
|
360
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (underLock.existingRecovery)
|
|
364
|
+
return underLock.existingRecovery;
|
|
365
|
+
if (underLock.evidenceFingerprint !== preflight.evidenceFingerprint) {
|
|
366
|
+
throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
|
|
367
|
+
taskId: task.id,
|
|
368
|
+
stepId,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return store.appendSidecarEvent(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
|
|
372
|
+
eventId: `RMR-${createUlid(now.getTime())}`,
|
|
373
|
+
recordedAt: now.toISOString(),
|
|
374
|
+
taskId: task.id,
|
|
375
|
+
stepId,
|
|
376
|
+
taskRevision: expectedRevision,
|
|
377
|
+
remediationEventId: underLock.remediation.eventId,
|
|
378
|
+
remediationEventHash: underLock.remediation.eventHash,
|
|
379
|
+
correctiveDecisionEventId: underLock.decision.eventId,
|
|
380
|
+
correctiveDecisionEventHash: underLock.decision.eventHash,
|
|
381
|
+
correctiveDecisionRecoveryEventId: underLock.correctiveRecovery.eventId,
|
|
382
|
+
correctiveDecisionRecoveryEventHash: underLock.correctiveRecovery.eventHash,
|
|
383
|
+
attemptOrdinal: 3,
|
|
384
|
+
planHash: currentTask.planHash,
|
|
385
|
+
actor: underLock.actor,
|
|
386
|
+
c1ClaimEventHash: underLock.c1ClaimEventHash,
|
|
387
|
+
writerLeaseTokenHash: underLock.writerLeaseTokenHash,
|
|
388
|
+
fromMode: 'ordinary',
|
|
389
|
+
toMode: 'corrective',
|
|
390
|
+
reason: 'alpha7.2-recovered-continue-fix-mode-omission',
|
|
391
|
+
}, {
|
|
392
|
+
validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, currentTask, context.file, context.line),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
if (existsSync(lockFile))
|
|
397
|
+
unlinkSync(lockFile);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function requireRemediationModeRecoveryAuthority(store, task, actor, writerToken) {
|
|
401
|
+
const posture = readTaskC1Posture(store, task);
|
|
402
|
+
const lease = readWriterLeaseForModeRecovery(store, task);
|
|
403
|
+
if (posture.state === 'pending') {
|
|
404
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Pending C1 handoff must be claimed before remediation mode recovery.');
|
|
405
|
+
}
|
|
406
|
+
if (posture.state === 'claimed') {
|
|
407
|
+
const tokenHash = writerToken ? sha256Hex(writerToken) : null;
|
|
408
|
+
if (!lease
|
|
409
|
+
|| actor !== posture.claimant
|
|
410
|
+
|| lease.owner !== posture.claimant
|
|
411
|
+
|| !writerToken
|
|
412
|
+
|| lease.token !== writerToken
|
|
413
|
+
|| tokenHash !== posture.writerLeaseTokenHash) {
|
|
414
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the claimed C1 actor and exact writer token.', {
|
|
415
|
+
taskId: task.id,
|
|
416
|
+
claimant: posture.claimant,
|
|
417
|
+
leaseOwner: lease?.owner ?? null,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return { c1ClaimEventHash: posture.latestEvent.eventHash, writerLeaseTokenHash: tokenHash };
|
|
421
|
+
}
|
|
422
|
+
if (lease && (lease.owner !== actor || !writerToken || lease.token !== writerToken)) {
|
|
423
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Existing writer lease is not owned by the remediation recovery actor/token.');
|
|
424
|
+
}
|
|
425
|
+
if (!lease && writerToken) {
|
|
426
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'A writer token was provided without an existing writer lease.');
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
c1ClaimEventHash: posture.latestEvent?.eventHash ?? null,
|
|
430
|
+
writerLeaseTokenHash: lease ? sha256Hex(lease.token) : null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function readWriterLeaseForModeRecovery(store, task) {
|
|
434
|
+
const file = path.join(store.lockRoot(task.projectId), `${task.id}.json`);
|
|
435
|
+
if (!existsSync(file))
|
|
436
|
+
return null;
|
|
437
|
+
let lease;
|
|
438
|
+
try {
|
|
439
|
+
lease = JSON.parse(readFileSync(file, 'utf8'));
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
throw new WorkflowError('STATE_CORRUPT', `Writer lease is unreadable: ${file}`);
|
|
443
|
+
}
|
|
444
|
+
if (lease.entityId !== task.id || typeof lease.owner !== 'string' || typeof lease.token !== 'string') {
|
|
445
|
+
throw new WorkflowError('STATE_CORRUPT', `Writer lease does not match Task ${task.id}.`);
|
|
446
|
+
}
|
|
447
|
+
return lease;
|
|
448
|
+
}
|
|
136
449
|
export function appendCorrectiveDecisionEvent(store, task, stepId, correctiveAudit, planRiskAudit, planHash, now) {
|
|
137
450
|
validateCorrectiveDecisionAudit(correctiveAudit);
|
|
138
451
|
const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
@@ -210,10 +523,11 @@ export function appendCorrectiveDecisionEvent(store, task, stepId, correctiveAud
|
|
|
210
523
|
validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
|
|
211
524
|
});
|
|
212
525
|
}
|
|
213
|
-
export function assertGuardedRemediationAttemptAllowed(store, task, stepId, planRiskAudit) {
|
|
526
|
+
export function assertGuardedRemediationAttemptAllowed(store, task, stepId, planRiskAudit, recoveredDecisionEventId = null) {
|
|
214
527
|
const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
215
528
|
return deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEvents, readCorrectiveDecisionEvents(store, task), {
|
|
216
529
|
enforceRuntimeGate: true,
|
|
530
|
+
recoveredDecisionEventId,
|
|
217
531
|
});
|
|
218
532
|
}
|
|
219
533
|
export function readGuardedRemediationPostureForStep(store, task, stepId, planRiskAudit) {
|
|
@@ -229,7 +543,7 @@ export function readGuardedRemediationPostureForStep(store, task, stepId, planRi
|
|
|
229
543
|
const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
230
544
|
if (remediationEvents.length < 2)
|
|
231
545
|
return null;
|
|
232
|
-
if (step.status === 'failed') {
|
|
546
|
+
if (step.status === 'failed' || step.status === 'planned') {
|
|
233
547
|
return deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEvents, readCorrectiveDecisionEvents(store, task));
|
|
234
548
|
}
|
|
235
549
|
if (task.status !== 'blocked' || step.status !== 'in_progress' || !step.evidence)
|
|
@@ -330,6 +644,18 @@ function deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEv
|
|
|
330
644
|
};
|
|
331
645
|
}
|
|
332
646
|
if (correctiveDecision.planHash !== task.planHash) {
|
|
647
|
+
if (correctiveDecision.decision === 'continue-fix'
|
|
648
|
+
&& options.recoveredDecisionEventId === correctiveDecision.eventId) {
|
|
649
|
+
return {
|
|
650
|
+
stepId,
|
|
651
|
+
guardedCategory,
|
|
652
|
+
attemptCount,
|
|
653
|
+
nextAttemptOrdinal,
|
|
654
|
+
mode: 'corrective',
|
|
655
|
+
correctiveDecision,
|
|
656
|
+
posture: 'continue-fix',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
333
659
|
if (correctiveDecision.decision === 'replan-required') {
|
|
334
660
|
return {
|
|
335
661
|
stepId,
|
|
@@ -540,6 +866,142 @@ function validateStoredCorrectiveDecisionEvent(event, task, file, line) {
|
|
|
540
866
|
normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
|
|
541
867
|
assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
|
|
542
868
|
}
|
|
869
|
+
function validateStoredCorrectiveDecisionRecoveryReference(event, task, file, line) {
|
|
870
|
+
if (!/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)
|
|
871
|
+
|| event.taskId !== task.id
|
|
872
|
+
|| event.triggeringAttemptCount !== 3) {
|
|
873
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference does not match the Task/ordinal.', {
|
|
874
|
+
file,
|
|
875
|
+
line,
|
|
876
|
+
taskId: task.id,
|
|
877
|
+
eventTaskId: event.taskId,
|
|
878
|
+
triggeringAttemptCount: event.triggeringAttemptCount,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
if (typeof event.stepId !== 'string' || !event.stepId.trim() || typeof event.actor !== 'string' || !event.actor.trim()) {
|
|
882
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference is missing Step or actor.', { file, line });
|
|
883
|
+
}
|
|
884
|
+
for (const hash of [
|
|
885
|
+
event.correctiveDecisionEventHash,
|
|
886
|
+
event.decisionPlanHash,
|
|
887
|
+
event.reboundPlanHash,
|
|
888
|
+
event.semanticPlanHash,
|
|
889
|
+
event.fromKnowledgeMapHash,
|
|
890
|
+
event.toKnowledgeMapHash,
|
|
891
|
+
event.sourcePlanRiskAuditEventHash,
|
|
892
|
+
event.reboundPlanRiskAuditEventHash,
|
|
893
|
+
event.knowledgeMapApprovalFingerprint,
|
|
894
|
+
event.taskAuthorizationFingerprint,
|
|
895
|
+
event.delegationGrantFingerprint,
|
|
896
|
+
event.c1EvidenceFingerprint,
|
|
897
|
+
event.evidenceFingerprint,
|
|
898
|
+
]) {
|
|
899
|
+
assertHash64(hash, `Corrective recovery hash at ${file}:${line}`);
|
|
900
|
+
}
|
|
901
|
+
if (!Number.isInteger(event.fromKnowledgeMapRevision)
|
|
902
|
+
|| event.fromKnowledgeMapRevision < 1
|
|
903
|
+
|| !Number.isInteger(event.toKnowledgeMapRevision)
|
|
904
|
+
|| event.toKnowledgeMapRevision < 1) {
|
|
905
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery Knowledge Map revisions are malformed.', { file, line });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function validateStoredRemediationModeRecovery(event, task, file, line) {
|
|
909
|
+
if (!/^RMR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
|
|
910
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery eventId is malformed.', { file, line });
|
|
911
|
+
}
|
|
912
|
+
if (event.taskId !== task.id || typeof event.stepId !== 'string' || !event.stepId.trim()) {
|
|
913
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match the current Task.', {
|
|
914
|
+
file,
|
|
915
|
+
line,
|
|
916
|
+
taskId: task.id,
|
|
917
|
+
eventTaskId: event.taskId,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1 || event.attemptOrdinal !== 3) {
|
|
921
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an invalid revision or ordinal.', { file, line });
|
|
922
|
+
}
|
|
923
|
+
if (event.fromMode !== 'ordinary'
|
|
924
|
+
|| event.toMode !== 'corrective'
|
|
925
|
+
|| event.reason !== 'alpha7.2-recovered-continue-fix-mode-omission') {
|
|
926
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an unsupported correction.', { file, line });
|
|
927
|
+
}
|
|
928
|
+
if (!/^RME-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.remediationEventId)
|
|
929
|
+
|| !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionEventId)
|
|
930
|
+
|| !/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionRecoveryEventId)
|
|
931
|
+
|| typeof event.actor !== 'string'
|
|
932
|
+
|| !event.actor.trim()) {
|
|
933
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery identifiers are malformed.', { file, line });
|
|
934
|
+
}
|
|
935
|
+
for (const [label, value] of [
|
|
936
|
+
['remediationEventHash', event.remediationEventHash],
|
|
937
|
+
['correctiveDecisionEventHash', event.correctiveDecisionEventHash],
|
|
938
|
+
['correctiveDecisionRecoveryEventHash', event.correctiveDecisionRecoveryEventHash],
|
|
939
|
+
['planHash', event.planHash],
|
|
940
|
+
]) {
|
|
941
|
+
assertHash64(value, `Remediation mode recovery ${label} at ${file}:${line}`);
|
|
942
|
+
}
|
|
943
|
+
if (event.c1ClaimEventHash !== null)
|
|
944
|
+
assertHash64(event.c1ClaimEventHash, `Remediation mode recovery C1 hash at ${file}:${line}`);
|
|
945
|
+
if (event.writerLeaseTokenHash !== null)
|
|
946
|
+
assertHash64(event.writerLeaseTokenHash, `Remediation mode recovery token hash at ${file}:${line}`);
|
|
947
|
+
}
|
|
948
|
+
function validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery) {
|
|
949
|
+
const mismatches = [
|
|
950
|
+
recovery.taskId !== remediation.taskId ? 'taskId' : null,
|
|
951
|
+
recovery.stepId !== remediation.stepId ? 'stepId' : null,
|
|
952
|
+
recovery.remediationEventId !== remediation.eventId ? 'remediationEventId' : null,
|
|
953
|
+
recovery.remediationEventHash !== remediation.eventHash ? 'remediationEventHash' : null,
|
|
954
|
+
recovery.correctiveDecisionEventId !== decision.eventId ? 'correctiveDecisionEventId' : null,
|
|
955
|
+
recovery.correctiveDecisionEventHash !== decision.eventHash ? 'correctiveDecisionEventHash' : null,
|
|
956
|
+
recovery.correctiveDecisionRecoveryEventId !== correctiveRecovery.eventId ? 'correctiveDecisionRecoveryEventId' : null,
|
|
957
|
+
recovery.correctiveDecisionRecoveryEventHash !== correctiveRecovery.eventHash ? 'correctiveDecisionRecoveryEventHash' : null,
|
|
958
|
+
recovery.attemptOrdinal !== remediation.attemptOrdinal ? 'attemptOrdinal' : null,
|
|
959
|
+
recovery.planHash !== correctiveRecovery.reboundPlanHash ? 'planHash' : null,
|
|
960
|
+
recovery.actor !== correctiveRecovery.actor ? 'actor' : null,
|
|
961
|
+
].filter((value) => value !== null);
|
|
962
|
+
if (mismatches.length > 0) {
|
|
963
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match its append-only evidence.', {
|
|
964
|
+
recoveryEventId: recovery.eventId,
|
|
965
|
+
mismatches,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function validateRemediationModeRecoveries(store, task, remediations, decisions, recoveries) {
|
|
970
|
+
const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task);
|
|
971
|
+
const recoveredEventIds = new Set();
|
|
972
|
+
for (const recovery of recoveries) {
|
|
973
|
+
if (recoveredEventIds.has(recovery.remediationEventId)) {
|
|
974
|
+
throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation mode recoveries target one event.', {
|
|
975
|
+
taskId: task.id,
|
|
976
|
+
remediationEventId: recovery.remediationEventId,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
const remediation = remediations.find((event) => event.eventId === recovery.remediationEventId) ?? null;
|
|
980
|
+
const decision = decisions.find((event) => event.eventId === recovery.correctiveDecisionEventId) ?? null;
|
|
981
|
+
const correctiveRecovery = correctiveRecoveries.find((event) => event.eventId === recovery.correctiveDecisionRecoveryEventId) ?? null;
|
|
982
|
+
if (!remediation || !decision || !correctiveRecovery) {
|
|
983
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references missing evidence.', {
|
|
984
|
+
taskId: task.id,
|
|
985
|
+
recoveryEventId: recovery.eventId,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
if (recovery.taskRevision !== task.revision
|
|
989
|
+
|| recovery.planHash !== task.planHash
|
|
990
|
+
|| remediation.mode !== 'ordinary'
|
|
991
|
+
|| remediation.attemptOrdinal !== 3
|
|
992
|
+
|| decision.decision !== 'continue-fix'
|
|
993
|
+
|| decision.triggeringAttemptCount !== 3
|
|
994
|
+
|| correctiveRecovery.correctiveDecisionEventId !== decision.eventId) {
|
|
995
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references an ineligible lifecycle shape.', {
|
|
996
|
+
taskId: task.id,
|
|
997
|
+
recoveryEventId: recovery.eventId,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery);
|
|
1001
|
+
recoveredEventIds.add(remediation.eventId);
|
|
1002
|
+
}
|
|
1003
|
+
return recoveredEventIds;
|
|
1004
|
+
}
|
|
543
1005
|
function validateRemediationAttemptOrdinals(events, task) {
|
|
544
1006
|
const attemptsByStep = new Map();
|
|
545
1007
|
for (const event of events) {
|
|
@@ -699,7 +1161,7 @@ function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents)
|
|
|
699
1161
|
}
|
|
700
1162
|
}
|
|
701
1163
|
}
|
|
702
|
-
function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions) {
|
|
1164
|
+
function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions, recoveredEventIds) {
|
|
703
1165
|
for (const event of remediationEvents) {
|
|
704
1166
|
if (event.attemptOrdinal <= 2) {
|
|
705
1167
|
if (event.mode !== 'ordinary') {
|
|
@@ -713,7 +1175,7 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
|
|
|
713
1175
|
}
|
|
714
1176
|
continue;
|
|
715
1177
|
}
|
|
716
|
-
if (event.mode === 'ordinary') {
|
|
1178
|
+
if (event.mode === 'ordinary' && !recoveredEventIds.has(event.eventId)) {
|
|
717
1179
|
throw new WorkflowError('STATE_CORRUPT', 'Guarded remediation attempt ordinals above 2 must not remain ordinary.', {
|
|
718
1180
|
taskId: task.id,
|
|
719
1181
|
stepId,
|
|
@@ -740,13 +1202,13 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
|
|
|
740
1202
|
}
|
|
741
1203
|
}
|
|
742
1204
|
}
|
|
743
|
-
function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions) {
|
|
1205
|
+
function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions, recoveredEventIds) {
|
|
744
1206
|
const stepIds = new Set(remediationEvents.map((event) => event.stepId));
|
|
745
1207
|
for (const stepId of stepIds) {
|
|
746
1208
|
const stepEvents = remediationEvents
|
|
747
1209
|
.filter((event) => event.stepId === stepId)
|
|
748
1210
|
.sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
|
|
749
|
-
validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions);
|
|
1211
|
+
validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions, recoveredEventIds);
|
|
750
1212
|
}
|
|
751
1213
|
}
|
|
752
1214
|
function validateCorrectiveDecisionAudit(audit) {
|