@abelxiaoxing/cadence 1.2.1 → 1.2.2

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.
@@ -20,8 +20,15 @@ Your output is one complete structured patch delivered through `abel_submit_resu
20
20
  - every operation path is within the declared phase boundary; paths that do
21
21
  not need a change may remain untouched;
22
22
  - preserve the expected parent-owned verification contract without claiming a result;
23
- - use a typed `context-request` for bounded context, task splitting, or an
24
- approval-boundary blocker instead of submitting a partial patch.
23
+ - use a typed `context-request` for bounded context or task splitting instead
24
+ of submitting a partial patch; classify every ref as a requested path with
25
+ `read | write` access, a source citation with path and line, or a
26
+ contract-diagnostic ref;
27
+ - report only the missing context and structural facts: never select
28
+ `approval-needed`, `boundary-review-needed`, a Gate, or a workflow stage;
29
+ - an `AGENTS.md` path is never a Worker write path;
30
+ - report an AGENTS read need separately, and leave every sealed AGENTS contract
31
+ and managed-block update to the parent control plane.
25
32
 
26
33
  If the submit tool rejects malformed operations, use its concrete error to correct the submission once in this same disposable session; a second rejected structural submission ends the session.
27
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and three package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -1525,19 +1525,46 @@ export function openPackageWorkflowControlEngine(
1525
1525
  if (!phase) {
1526
1526
  return { kind: "paused", code: "task-phase-unavailable" };
1527
1527
  }
1528
+ const taskPhases = Object.values(input.task.phases);
1529
+ const executionBoundary = input.artifactCorrection
1530
+ ? {
1531
+ read: [
1532
+ ...new Set(
1533
+ taskPhases.flatMap((boundary) => [
1534
+ ...boundary.read,
1535
+ ...boundary.write,
1536
+ ...boundary.delete,
1537
+ ]),
1538
+ ),
1539
+ ].sort(),
1540
+ write: [
1541
+ ...new Set(taskPhases.flatMap((boundary) => boundary.write)),
1542
+ ].sort(),
1543
+ delete: [
1544
+ ...new Set(taskPhases.flatMap((boundary) => boundary.delete)),
1545
+ ].sort(),
1546
+ }
1547
+ : {
1548
+ read: [...phase.read],
1549
+ write: [...phase.write],
1550
+ delete: [...phase.delete],
1551
+ };
1528
1552
  const phaseContract = {
1529
1553
  candidateId: input.candidateArtifact.identity.candidateId,
1530
1554
  taskId: input.taskId,
1531
1555
  phase: input.phase,
1532
- readSet: [...phase.read],
1533
- writeSet: [...phase.write],
1534
- deleteSet: [...phase.delete],
1556
+ readSet: executionBoundary.read,
1557
+ writeSet: executionBoundary.write,
1558
+ deleteSet: executionBoundary.delete,
1535
1559
  verification: structuredClone(phase.verification),
1536
1560
  agentsImpact: input.task.agents.impact,
1537
1561
  agentsTarget: input.task.agents.target ?? null,
1538
1562
  agentsManagedOnly: true,
1539
1563
  agentsWriteAllowed: false,
1540
1564
  impactClosure: structuredClone(input.task.impactClosure),
1565
+ ...(input.artifactCorrection
1566
+ ? { artifactCorrection: structuredClone(input.artifactCorrection) }
1567
+ : {}),
1541
1568
  ...(input.repair
1542
1569
  ? {
1543
1570
  repair: {
@@ -1570,7 +1597,11 @@ export function openPackageWorkflowControlEngine(
1570
1597
  path.resolve(input.workspaceRoot, root),
1571
1598
  ),
1572
1599
  allowedPaths: [
1573
- ...new Set([...phase.read, ...phase.write, ...phase.delete]),
1600
+ ...new Set([
1601
+ ...executionBoundary.read,
1602
+ ...executionBoundary.write,
1603
+ ...executionBoundary.delete,
1604
+ ]),
1574
1605
  ],
1575
1606
  timeoutMs: LIMITS.phaseTimeoutMs,
1576
1607
  signal: input.signal,
@@ -1600,11 +1631,28 @@ export function openPackageWorkflowControlEngine(
1600
1631
  }
1601
1632
  const result = child.result;
1602
1633
  if (result.kind === "context-request") {
1603
- return classifyCandidateContextRequest(result, [
1604
- ...phase.read,
1605
- ...phase.write,
1606
- ...phase.delete,
1607
- ]);
1634
+ return classifyCandidateContextRequest(result, {
1635
+ phase: input.phase,
1636
+ readPaths: phase.read,
1637
+ writePaths: phase.write,
1638
+ deletePaths: phase.delete,
1639
+ taskPaths: [
1640
+ ...new Set(
1641
+ Object.values(input.task.phases).flatMap((boundary) => [
1642
+ ...boundary.read,
1643
+ ...boundary.write,
1644
+ ...boundary.delete,
1645
+ ]),
1646
+ ),
1647
+ ],
1648
+ redWritePaths: input.task.phases.red.write,
1649
+ agents: {
1650
+ impact: input.task.agents.impact,
1651
+ ...(input.task.agents.target
1652
+ ? { target: input.task.agents.target }
1653
+ : {}),
1654
+ },
1655
+ });
1608
1656
  }
1609
1657
  if (result.kind !== "sealed-candidate") {
1610
1658
  return { kind: "retryable", code: "candidate-diff-invalid" };
package/src/run-store.ts CHANGED
@@ -917,7 +917,23 @@ export class RunStore {
917
917
  code?: string;
918
918
  lease?: OperationLease;
919
919
  }): RunProjection {
920
+ return this.transitionAtomically(input, () => {});
921
+ }
922
+
923
+ transitionAtomically(
924
+ input: {
925
+ runId: string;
926
+ to: RunState;
927
+ operationId: string;
928
+ code?: string;
929
+ lease?: OperationLease;
930
+ },
931
+ write: (database: DatabaseSync) => void,
932
+ ): RunProjection {
920
933
  requireIdentifier(input.operationId, "operation-id");
934
+ if (typeof write !== "function") {
935
+ throw new Error("state-transition-write-invalid");
936
+ }
921
937
  return this.#transaction(() => {
922
938
  if (input.lease) {
923
939
  if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
@@ -935,6 +951,7 @@ export class RunStore {
935
951
  to: input.to,
936
952
  ...(input.code ? { code: input.code } : {}),
937
953
  });
954
+ write(this.#database);
938
955
  this.#recordOperation(
939
956
  input.runId,
940
957
  input.operationId,
@@ -9,6 +9,7 @@ import {
9
9
  type CandidateFailure,
10
10
  type DiffResult,
11
11
  type EvidenceResult,
12
+ isAgentsPath,
12
13
  isValidRelativePath,
13
14
  type SubmitFinalCategory,
14
15
  type SubmitSchemaState,
@@ -144,15 +145,57 @@ function candidateArtifactSchema(candidateId: string) {
144
145
  code: Type.Union([
145
146
  Type.Literal("approved-context-needed"),
146
147
  Type.Literal("task-split-needed"),
147
- Type.Literal("boundary-review-needed"),
148
148
  ]),
149
- refs: Type.Array(Type.String(), { maxItems: 32 }),
149
+ refs: Type.Array(
150
+ Type.Union([
151
+ Type.String(),
152
+ Type.Object(
153
+ {
154
+ kind: Type.Literal("requested-path"),
155
+ path: Type.String(),
156
+ access: Type.Union([
157
+ Type.Literal("read"),
158
+ Type.Literal("write"),
159
+ ]),
160
+ },
161
+ { additionalProperties: false },
162
+ ),
163
+ Type.Object(
164
+ {
165
+ kind: Type.Literal("source-citation"),
166
+ path: Type.String(),
167
+ line: Type.Integer({ minimum: 1 }),
168
+ },
169
+ { additionalProperties: false },
170
+ ),
171
+ Type.Object(
172
+ {
173
+ kind: Type.Literal("contract-diagnostic"),
174
+ ref: Type.String(),
175
+ },
176
+ { additionalProperties: false },
177
+ ),
178
+ ]),
179
+ { minItems: 1, maxItems: 32 },
180
+ ),
150
181
  },
151
182
  { additionalProperties: false },
152
183
  ),
153
184
  ]);
154
185
  }
155
186
 
187
+ export type CandidateContextRequestCode =
188
+ | "approved-context-needed"
189
+ | "task-split-needed"
190
+ | "boundary-review-needed";
191
+
192
+ export type CandidateContextRef =
193
+ | { kind: "requested-path"; path: string; access: "read" | "write" }
194
+ | { kind: "source-citation"; path: string; line: number }
195
+ | { kind: "contract-diagnostic"; ref: string };
196
+
197
+ export type CandidateContextRefInput = string | CandidateContextRef;
198
+
156
199
  export type CandidateArtifactSubmissionResult =
157
200
  | {
158
201
  kind: "sealed-candidate";
@@ -166,11 +209,8 @@ export type CandidateArtifactSubmissionResult =
166
209
  | {
167
210
  kind: "context-request";
168
211
  candidateId: string;
169
- code:
170
- | "approved-context-needed"
171
- | "task-split-needed"
172
- | "boundary-review-needed";
173
- refs: string[];
212
+ code: CandidateContextRequestCode;
213
+ refs: CandidateContextRef[];
174
214
  };
175
215
 
176
216
  export type CandidateContextRequest = Extract<
@@ -178,18 +218,114 @@ export type CandidateContextRequest = Extract<
178
218
  { kind: "context-request" }
179
219
  >;
180
220
 
221
+ export interface CandidateContextBoundary {
222
+ phase: "red" | "green" | "refactor";
223
+ readPaths: readonly string[];
224
+ writePaths: readonly string[];
225
+ deletePaths?: readonly string[];
226
+ taskPaths: readonly string[];
227
+ redWritePaths: readonly string[];
228
+ agents: {
229
+ impact: "none" | "update-existing" | "create-index" | "remove-index";
230
+ target?: string;
231
+ };
232
+ }
233
+
181
234
  export interface ClassifiedCandidateContextRequest {
182
- kind: "paused" | "approval-needed";
235
+ kind: "paused" | "retryable" | "approval-needed";
183
236
  code:
184
237
  | "approved-context-needed"
185
238
  | "task-split-needed"
186
- | "boundary-review-needed";
239
+ | "boundary-review-needed"
240
+ | "red-artifact-constraint"
241
+ | "agents-context-needed"
242
+ | "agents-write-parent-owned"
243
+ | "agents-contract-insufficient";
187
244
  contextRequest: {
188
- code: CandidateContextRequest["code"];
189
- refs: string[];
245
+ code: CandidateContextRequestCode;
246
+ refs: CandidateContextRef[];
190
247
  };
191
248
  }
192
249
 
250
+ const LEGACY_CITATION = /^(.*):(\d+)$/u;
251
+ const LEGACY_DIAGNOSTIC =
252
+ /^(?:phase-contract|task-contract|sealed-contract|workflow-contract)\.[a-z0-9._-]+$/iu;
253
+
254
+ function validDiagnosticRef(value: unknown): value is string {
255
+ return (
256
+ typeof value === "string" &&
257
+ value.length > 0 &&
258
+ value.length <= 512 &&
259
+ value.trim() === value &&
260
+ !/[\r\n\0]/u.test(value)
261
+ );
262
+ }
263
+
264
+ function normalizeCandidateContextRef(
265
+ input: CandidateContextRefInput,
266
+ ): CandidateContextRef {
267
+ if (typeof input === "string") {
268
+ const citation = LEGACY_CITATION.exec(input);
269
+ if (citation && isValidRelativePath(citation[1])) {
270
+ const line = Number(citation[2]);
271
+ if (Number.isSafeInteger(line) && line >= 1) {
272
+ return { kind: "source-citation", path: citation[1], line };
273
+ }
274
+ }
275
+ if (LEGACY_DIAGNOSTIC.test(input)) {
276
+ return { kind: "contract-diagnostic", ref: input };
277
+ }
278
+ if (!isValidRelativePath(input)) {
279
+ throw new Error("context request refs are invalid");
280
+ }
281
+ return { kind: "requested-path", path: input, access: "read" };
282
+ }
283
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
284
+ throw new Error("context request refs are invalid");
285
+ }
286
+ if (
287
+ input.kind === "requested-path" &&
288
+ Object.keys(input).length === 3 &&
289
+ isValidRelativePath(input.path) &&
290
+ (input.access === "read" || input.access === "write")
291
+ ) {
292
+ return { ...input };
293
+ }
294
+ if (
295
+ input.kind === "source-citation" &&
296
+ Object.keys(input).length === 3 &&
297
+ isValidRelativePath(input.path) &&
298
+ Number.isSafeInteger(input.line) &&
299
+ input.line >= 1
300
+ ) {
301
+ return { ...input };
302
+ }
303
+ if (
304
+ input.kind === "contract-diagnostic" &&
305
+ Object.keys(input).length === 2 &&
306
+ validDiagnosticRef(input.ref)
307
+ ) {
308
+ return { ...input };
309
+ }
310
+ throw new Error("context request refs are invalid");
311
+ }
312
+
313
+ export function normalizeCandidateContextRefs(
314
+ value: unknown,
315
+ ): CandidateContextRef[] {
316
+ if (!Array.isArray(value) || value.length === 0 || value.length > 32) {
317
+ throw new Error("context request refs are invalid");
318
+ }
319
+ const refs = value.map((entry) =>
320
+ normalizeCandidateContextRef(entry as CandidateContextRefInput),
321
+ );
322
+ const identities = refs.map((ref) => JSON.stringify(ref));
323
+ if (new Set(identities).size !== refs.length) {
324
+ throw new Error("context request refs are invalid");
325
+ }
326
+ return refs;
327
+ }
328
+
193
329
  function contextRefWithinBoundary(relative: string, approved: string): boolean {
194
330
  return (
195
331
  approved === "." ||
@@ -198,23 +334,108 @@ function contextRefWithinBoundary(relative: string, approved: string): boolean {
198
334
  );
199
335
  }
200
336
 
337
+ function normalizedBoundary(
338
+ boundary: readonly string[] | CandidateContextBoundary,
339
+ ): CandidateContextBoundary {
340
+ if (!Array.isArray(boundary)) return boundary as CandidateContextBoundary;
341
+ return {
342
+ phase: "green",
343
+ readPaths: boundary,
344
+ writePaths: [],
345
+ deletePaths: [],
346
+ taskPaths: boundary,
347
+ redWritePaths: [],
348
+ agents: { impact: "none" },
349
+ };
350
+ }
351
+
352
+ function pathWithinAny(relative: string, approved: readonly string[]): boolean {
353
+ return approved.some((path) => contextRefWithinBoundary(relative, path));
354
+ }
355
+
201
356
  export function classifyCandidateContextRequest(
202
- request: CandidateContextRequest,
203
- approvedPaths: readonly string[],
357
+ request: {
358
+ kind: "context-request";
359
+ candidateId: string;
360
+ code: CandidateContextRequestCode;
361
+ refs: readonly CandidateContextRefInput[];
362
+ },
363
+ inputBoundary: readonly string[] | CandidateContextBoundary,
204
364
  ): ClassifiedCandidateContextRequest {
205
- const refs = [...request.refs];
206
- const inBoundary =
207
- refs.length > 0 &&
208
- refs.every((relative) =>
209
- approvedPaths.some((approved) =>
210
- contextRefWithinBoundary(relative, approved),
365
+ const refs = normalizeCandidateContextRefs(request.refs);
366
+ const boundary = normalizedBoundary(inputBoundary);
367
+ const phasePaths = [
368
+ ...boundary.readPaths,
369
+ ...boundary.writePaths,
370
+ ...(boundary.deletePaths ?? []),
371
+ ];
372
+ const requested = refs.filter(
373
+ (ref): ref is Extract<CandidateContextRef, { kind: "requested-path" }> =>
374
+ ref.kind === "requested-path",
375
+ );
376
+ const ordinaryRequested = requested.filter((ref) => !isAgentsPath(ref.path));
377
+ const outsideOrdinary = ordinaryRequested.filter(
378
+ (ref) =>
379
+ !pathWithinAny(
380
+ ref.path,
381
+ ref.access === "write"
382
+ ? [...boundary.writePaths, ...(boundary.deletePaths ?? [])]
383
+ : phasePaths,
211
384
  ),
385
+ );
386
+ const agentsRequested = requested.filter((ref) => isAgentsPath(ref.path));
387
+ const agentsWrites = agentsRequested.filter((ref) => ref.access === "write");
388
+ const citedRedArtifacts = refs.filter(
389
+ (ref): ref is Extract<CandidateContextRef, { kind: "source-citation" }> =>
390
+ ref.kind === "source-citation" &&
391
+ pathWithinAny(ref.path, boundary.redWritePaths) &&
392
+ !pathWithinAny(ref.path, [
393
+ ...boundary.writePaths,
394
+ ...(boundary.deletePaths ?? []),
395
+ ]),
396
+ );
397
+ const requestedRedArtifacts = outsideOrdinary.filter(
398
+ (ref) =>
399
+ pathWithinAny(ref.path, boundary.redWritePaths) &&
400
+ pathWithinAny(ref.path, boundary.taskPaths),
401
+ );
402
+ const redArtifactConstraint =
403
+ boundary.phase === "green" &&
404
+ (citedRedArtifacts.length > 0 || requestedRedArtifacts.length > 0) &&
405
+ outsideOrdinary.every((ref) => requestedRedArtifacts.includes(ref)) &&
406
+ agentsRequested.every((ref) => ref.access === "read");
407
+
408
+ let classification: Pick<ClassifiedCandidateContextRequest, "kind" | "code">;
409
+ if (outsideOrdinary.length > 0 && !redArtifactConstraint) {
410
+ classification = {
411
+ kind: "approval-needed",
412
+ code: "boundary-review-needed",
413
+ };
414
+ } else if (agentsWrites.length > 0) {
415
+ const targetMismatch = agentsWrites.some(
416
+ (ref) =>
417
+ boundary.agents.impact === "none" ||
418
+ boundary.agents.target === undefined ||
419
+ ref.path !== boundary.agents.target,
212
420
  );
213
- const approvalNeeded =
214
- !inBoundary || request.code === "boundary-review-needed";
421
+ classification = targetMismatch
422
+ ? { kind: "approval-needed", code: "agents-contract-insufficient" }
423
+ : { kind: "paused", code: "agents-write-parent-owned" };
424
+ } else if (redArtifactConstraint) {
425
+ classification = { kind: "retryable", code: "red-artifact-constraint" };
426
+ } else if (agentsRequested.length > 0) {
427
+ classification = { kind: "paused", code: "agents-context-needed" };
428
+ } else {
429
+ classification = {
430
+ kind: "paused",
431
+ code:
432
+ request.code === "task-split-needed"
433
+ ? "task-split-needed"
434
+ : "approved-context-needed",
435
+ };
436
+ }
215
437
  return {
216
- kind: approvalNeeded ? "approval-needed" : "paused",
217
- code: inBoundary ? request.code : "boundary-review-needed",
438
+ ...classification,
218
439
  contextRequest: {
219
440
  code: request.code,
220
441
  refs,
@@ -391,18 +612,17 @@ export function createCandidateArtifactTool(
391
612
  terminate: true,
392
613
  };
393
614
  }
394
- const refs = value.refs as string[];
395
- if (
396
- refs.some((relative) => !isValidRelativePath(relative)) ||
397
- new Set(refs).size !== refs.length
398
- ) {
615
+ let refs: CandidateContextRef[];
616
+ try {
617
+ refs = normalizeCandidateContextRefs(value.refs);
618
+ } catch (error) {
399
619
  schema = "invalid";
400
620
  failure = {
401
621
  kind: "artifact",
402
622
  code: "invalid-structural-result",
403
623
  stage: "structural-submit",
404
624
  };
405
- throw new Error("context request refs are invalid");
625
+ throw error;
406
626
  }
407
627
  result = {
408
628
  kind: "context-request",
@@ -411,7 +631,7 @@ export function createCandidateArtifactTool(
411
631
  CandidateArtifactSubmissionResult,
412
632
  { kind: "context-request" }
413
633
  >["code"],
414
- refs: [...refs],
634
+ refs,
415
635
  };
416
636
  return {
417
637
  content: [
@@ -45,7 +45,13 @@ import { type OperationLease, RunStore } from "./run-store.ts";
45
45
  import { observeSafePath } from "./safe-path.ts";
46
46
  import type { ResolvedStateRoot } from "./state-root.ts";
47
47
  import type { WorkflowActivityUpdate } from "./subagent-activity.ts";
48
- import type { CandidateArtifactSubmission } from "./submit-tool.ts";
48
+ import {
49
+ type CandidateArtifactSubmission,
50
+ type CandidateContextBoundary,
51
+ type CandidateContextRef,
52
+ classifyCandidateContextRequest,
53
+ normalizeCandidateContextRefs,
54
+ } from "./submit-tool.ts";
49
55
  import {
50
56
  type BeginCandidateInput,
51
57
  TASK_LEDGER_LIMITS,
@@ -171,7 +177,14 @@ interface WorkflowContextRequest {
171
177
  | "approved-context-needed"
172
178
  | "task-split-needed"
173
179
  | "boundary-review-needed";
174
- refs: string[];
180
+ refs: CandidateContextRef[];
181
+ }
182
+
183
+ interface RedArtifactCorrection {
184
+ code: "red-artifact-constraint";
185
+ attempt: number;
186
+ maxAttempts: number;
187
+ contextRequest?: WorkflowContextRequest;
175
188
  }
176
189
 
177
190
  export const APPROVAL_AUTHORITY_CATEGORIES = [
@@ -296,6 +309,7 @@ export interface WorkflowWorker {
296
309
  attribution: "introduced";
297
310
  failureIdentities: string[];
298
311
  };
312
+ artifactCorrection?: RedArtifactCorrection;
299
313
  signal: AbortSignal;
300
314
  onActivity?: (
301
315
  event: Pick<
@@ -568,24 +582,72 @@ function normalizeWorkflowContextRequest(
568
582
  "approved-context-needed",
569
583
  "task-split-needed",
570
584
  "boundary-review-needed",
571
- ].includes(String(value.code)) ||
572
- !Array.isArray(value.refs) ||
573
- value.refs.length === 0 ||
574
- value.refs.length > 32 ||
575
- value.refs.some(
576
- (relative) =>
577
- typeof relative !== "string" || !isValidRelativePath(relative),
578
- ) ||
579
- new Set(value.refs).size !== value.refs.length
585
+ ].includes(String(value.code))
580
586
  ) {
581
587
  throw new Error("workflow-context-request-invalid");
582
588
  }
589
+ let refs: CandidateContextRef[];
590
+ try {
591
+ refs = normalizeCandidateContextRefs(value.refs);
592
+ } catch {
593
+ throw new Error("workflow-context-request-invalid");
594
+ }
583
595
  return {
584
596
  code: value.code as WorkflowContextRequest["code"],
585
- refs: [...(value.refs as string[])],
597
+ refs,
586
598
  };
587
599
  }
588
600
 
601
+ function contextApprovalRefs(request: WorkflowContextRequest): string[] {
602
+ return request.refs.flatMap((ref) =>
603
+ ref.kind === "requested-path" ? [ref.path] : [],
604
+ );
605
+ }
606
+
607
+ function contextBoundaryForTask(
608
+ task: PlanTaskDraft,
609
+ phase: "red" | "green" | "refactor",
610
+ ): CandidateContextBoundary {
611
+ const boundary = task.phases[phase];
612
+ if (!boundary) throw new Error("workflow-task-phase-invalid");
613
+ return {
614
+ phase,
615
+ readPaths: boundary.read,
616
+ writePaths: boundary.write,
617
+ deletePaths: boundary.delete,
618
+ taskPaths: [
619
+ ...new Set(
620
+ Object.values(task.phases).flatMap((candidate) => [
621
+ ...candidate.read,
622
+ ...candidate.write,
623
+ ...candidate.delete,
624
+ ]),
625
+ ),
626
+ ],
627
+ redWritePaths: task.phases.red.write,
628
+ agents: {
629
+ impact: task.agents.impact,
630
+ ...(task.agents.target ? { target: task.agents.target } : {}),
631
+ },
632
+ };
633
+ }
634
+
635
+ function classifyPersistedContextRequest(
636
+ request: WorkflowContextRequest,
637
+ task: PlanTaskDraft,
638
+ phase: "red" | "green" | "refactor",
639
+ ) {
640
+ return classifyCandidateContextRequest(
641
+ {
642
+ kind: "context-request",
643
+ candidateId: "candidate-durable-reclassification",
644
+ code: request.code,
645
+ refs: request.refs,
646
+ },
647
+ contextBoundaryForTask(task, phase),
648
+ );
649
+ }
650
+
589
651
  function approvalRequirement(
590
652
  code: string,
591
653
  contextRequest?: WorkflowContextRequest,
@@ -599,7 +661,7 @@ function approvalRequirement(
599
661
  return {
600
662
  category: requirement.category,
601
663
  requiredGates: [...requirement.requiredGates],
602
- refs: contextRequest ? [...contextRequest.refs] : [],
664
+ refs: contextRequest ? contextApprovalRefs(contextRequest) : [],
603
665
  };
604
666
  }
605
667
 
@@ -1282,6 +1344,7 @@ export interface DurableWorkflowEngineOptions {
1282
1344
  attribution: "introduced";
1283
1345
  failureIdentities: string[];
1284
1346
  };
1347
+ artifactCorrection?: RedArtifactCorrection;
1285
1348
  signal: AbortSignal;
1286
1349
  onHeaders(): void;
1287
1350
  onProgress(): void;
@@ -2319,6 +2382,9 @@ class DurableWorkflowComposition
2319
2382
  ledger: TaskLedger,
2320
2383
  input: Parameters<WorkflowWorker["runAttempt"]>[0],
2321
2384
  ): WorkflowAttemptOutcome | undefined {
2385
+ if (input.artifactCorrection?.code === "red-artifact-constraint") {
2386
+ return undefined;
2387
+ }
2322
2388
  const projection = ledger.projection({
2323
2389
  runId: input.runId,
2324
2390
  taskId: input.taskId,
@@ -2925,6 +2991,9 @@ class DurableWorkflowComposition
2925
2991
  },
2926
2992
  route: attempt.route,
2927
2993
  repair,
2994
+ ...(request.artifactCorrection
2995
+ ? { artifactCorrection: request.artifactCorrection }
2996
+ : {}),
2928
2997
  signal: attempt.signal,
2929
2998
  onHeaders: attempt.onHeaders,
2930
2999
  onProgress: attempt.onProgress,
@@ -2972,7 +3041,8 @@ class DurableWorkflowComposition
2972
3041
  proposal.kind !== "sealed-candidate"
2973
3042
  ) {
2974
3043
  return proposal.kind === "operation-cancelled" ||
2975
- proposal.kind === "paused"
3044
+ proposal.kind === "paused" ||
3045
+ proposal.kind === "approval-needed"
2976
3046
  ? withRoute(proposal)
2977
3047
  : reject(proposal, {
2978
3048
  category: "artifact",
@@ -3213,7 +3283,9 @@ class DurableWorkflowComposition
3213
3283
  changes: revisionChanges(proposalRoot, approvedPaths),
3214
3284
  });
3215
3285
  const requiredOutputs = request.plan.outputs.filter(
3216
- (output) => output.producer.taskId === request.taskId,
3286
+ (output) =>
3287
+ output.producer.taskId === request.taskId &&
3288
+ (!request.artifactCorrection || output.producer.phase !== "refactor"),
3217
3289
  );
3218
3290
  if (
3219
3291
  requiredOutputs.some(
@@ -3352,7 +3424,7 @@ class DurableWorkflowComposition
3352
3424
  #verifiedPhaseReplay(
3353
3425
  ledger: TaskLedger,
3354
3426
  input: Parameters<WorkflowWorker["runAttempt"]>[0],
3355
- ): { artifactHash: string; exitCode: number } {
3427
+ ): { artifactHash: string; exitCode: number; isolatedRevisionId: string } {
3356
3428
  const projection = ledger.projection({
3357
3429
  runId: input.runId,
3358
3430
  taskId: input.taskId,
@@ -3366,6 +3438,8 @@ class DurableWorkflowComposition
3366
3438
  !event ||
3367
3439
  typeof event.artifactHash !== "string" ||
3368
3440
  !SHA256.test(event.artifactHash) ||
3441
+ typeof event.isolatedRevisionId !== "string" ||
3442
+ !SHA256.test(event.isolatedRevisionId) ||
3369
3443
  typeof event.exitCode !== "number" ||
3370
3444
  event.actualClassification !== expectedClassification(input.phase)
3371
3445
  ) {
@@ -3374,6 +3448,285 @@ class DurableWorkflowComposition
3374
3448
  return {
3375
3449
  artifactHash: event.artifactHash,
3376
3450
  exitCode: event.exitCode,
3451
+ isolatedRevisionId: event.isolatedRevisionId,
3452
+ };
3453
+ }
3454
+
3455
+ async #runRedArtifactCorrection(input: {
3456
+ resources: DurableRunResources;
3457
+ ledger: TaskLedger;
3458
+ baseline?: DurableVerificationBaseline;
3459
+ attempt: Parameters<WorkflowWorker["runAttempt"]>[0];
3460
+ }): Promise<WorkflowAttemptOutcome> {
3461
+ const { resources, ledger } = input;
3462
+ const request = input.attempt;
3463
+ if (
3464
+ request.phase !== "green" ||
3465
+ request.artifactCorrection?.code !== "red-artifact-constraint"
3466
+ ) {
3467
+ throw new Error("workflow-red-artifact-correction-invalid");
3468
+ }
3469
+ const priorRed = this.#verifiedPhaseReplay(ledger, {
3470
+ ...request,
3471
+ phase: "red",
3472
+ });
3473
+ if (!hasVerificationLifecycle(request.plan)) {
3474
+ return { kind: "paused", code: "red-artifact-constraint" };
3475
+ }
3476
+ const stableRevisionId = resources.currentRevisionId;
3477
+ const correctionPaths = [
3478
+ ...new Set(
3479
+ Object.values(request.task.phases).flatMap((phase) => [
3480
+ ...phase.write,
3481
+ ...phase.delete,
3482
+ ]),
3483
+ ),
3484
+ ];
3485
+ const rejectCorrection = async <T extends WorkflowAttemptOutcome>(
3486
+ outcome: T,
3487
+ ): Promise<T> => {
3488
+ if (resources.currentRevisionId !== stableRevisionId) {
3489
+ await this.#rollbackWorkspacePaths({
3490
+ resources,
3491
+ baseRevisionId: stableRevisionId,
3492
+ rejectedRevisionId: resources.currentRevisionId,
3493
+ paths: correctionPaths,
3494
+ });
3495
+ }
3496
+ return outcome;
3497
+ };
3498
+ const failureIdentities = [
3499
+ hash(
3500
+ "red-artifact-constraint",
3501
+ request.taskId,
3502
+ request.task.phases.red.verification.id,
3503
+ ),
3504
+ ];
3505
+ const correctionAttemptUsed = request.artifactCorrection.attempt;
3506
+ if (
3507
+ correctionAttemptUsed < 1 ||
3508
+ correctionAttemptUsed > request.artifactCorrection.maxAttempts
3509
+ ) {
3510
+ throw new Error("workflow-red-artifact-correction-invalid");
3511
+ }
3512
+ const repaired = await this.#runRepairCandidate({
3513
+ resources,
3514
+ ledger,
3515
+ attempt: request,
3516
+ repairAttempt: correctionAttemptUsed,
3517
+ failureIdentities,
3518
+ });
3519
+ if (repaired.kind !== "repair-committed") {
3520
+ return rejectCorrection(repaired);
3521
+ }
3522
+
3523
+ const correctedRevision = resources.workspaces.getRevision(
3524
+ repaired.commit.isolatedRevisionId,
3525
+ );
3526
+ const acceptedRedRevision = resources.workspaces.getRevision(
3527
+ priorRed.isolatedRevisionId,
3528
+ );
3529
+ if (!acceptedRedRevision.parentRevisionId) {
3530
+ throw new Error("workflow-ledger-revision-base-invalid");
3531
+ }
3532
+ const correctedRedChanges: Record<
3533
+ string,
3534
+ { kind: "absent" } | { kind: "file"; bytes: Uint8Array; mode: number }
3535
+ > = {};
3536
+ for (const relative of [
3537
+ ...new Set([
3538
+ ...request.task.phases.red.write,
3539
+ ...request.task.phases.red.delete,
3540
+ ]),
3541
+ ]) {
3542
+ const entry = correctedRevision.entries[relative];
3543
+ correctedRedChanges[relative] =
3544
+ entry?.kind === "file"
3545
+ ? {
3546
+ kind: "file",
3547
+ bytes: resources.artifacts.read(entry.hash),
3548
+ mode: entry.mode,
3549
+ }
3550
+ : { kind: "absent" };
3551
+ }
3552
+ const correctedRedRevision = resources.workspaces.createRevision({
3553
+ parentRevisionId: acceptedRedRevision.parentRevisionId,
3554
+ changes: correctedRedChanges,
3555
+ });
3556
+ const verifyCorrectedContract = async (input: {
3557
+ phase: "red" | "green";
3558
+ revisionId: string;
3559
+ verification: StructuredVerificationContract;
3560
+ }): Promise<DurablePhaseVerificationResult> => {
3561
+ const root = mkdtempSync(path.join(resources.root, "correction-check-"));
3562
+ try {
3563
+ resources.workspaces.materialize(input.revisionId, root);
3564
+ return await this.#options.verifyPhase({
3565
+ runId: request.runId,
3566
+ deliveryRevision: request.deliveryRevision,
3567
+ taskId: request.taskId,
3568
+ phase: input.phase,
3569
+ root,
3570
+ verification: structuredClone(input.verification),
3571
+ signal: request.signal,
3572
+ });
3573
+ } finally {
3574
+ rmSync(root, { recursive: true, force: true });
3575
+ }
3576
+ };
3577
+ emitWorkflowActivity(request.onActivity, { state: "verifying" });
3578
+ const redVerification = await verifyCorrectedContract({
3579
+ phase: "red",
3580
+ revisionId: correctedRedRevision.revisionId,
3581
+ verification: request.task.phases.red.verification,
3582
+ });
3583
+ if (!redVerification.ok) {
3584
+ return rejectCorrection({
3585
+ ...redVerification,
3586
+ ...(redVerification.kind === "retryable"
3587
+ ? { retryPolicy: "verification" as const }
3588
+ : {}),
3589
+ });
3590
+ }
3591
+ if (
3592
+ redVerification.classification !== "expected-red" ||
3593
+ redVerification.exitCode === 0
3594
+ ) {
3595
+ return rejectCorrection({
3596
+ kind: "retryable",
3597
+ code: "verification-rejected",
3598
+ retryPolicy: "verification",
3599
+ });
3600
+ }
3601
+ const greenVerification = await verifyCorrectedContract({
3602
+ phase: "green",
3603
+ revisionId: repaired.commit.isolatedRevisionId,
3604
+ verification: request.task.phases.green.verification,
3605
+ });
3606
+ if (!greenVerification.ok) {
3607
+ return rejectCorrection({
3608
+ ...greenVerification,
3609
+ ...(greenVerification.kind === "retryable"
3610
+ ? { retryPolicy: "verification" as const }
3611
+ : {}),
3612
+ });
3613
+ }
3614
+ if (
3615
+ greenVerification.classification !== "expected-green" ||
3616
+ greenVerification.exitCode !== 0
3617
+ ) {
3618
+ return rejectCorrection({
3619
+ kind: "retryable",
3620
+ code: "verification-rejected",
3621
+ retryPolicy: "verification",
3622
+ });
3623
+ }
3624
+
3625
+ const repairEvent: VerifiedTaskEvent = {
3626
+ runId: request.runId,
3627
+ taskId: request.taskId,
3628
+ eventId: `red-artifact-correction-${hash(
3629
+ request.operationId,
3630
+ request.taskId,
3631
+ repaired.commit.artifactHash,
3632
+ ).slice(0, 40)}`,
3633
+ kind: "repair-verified",
3634
+ phase: request.phase,
3635
+ attempt: correctionAttemptUsed,
3636
+ commandId: request.task.repairVerification.id,
3637
+ exitCode: 0,
3638
+ diagnostic: repaired.commit.diagnostic,
3639
+ artifactHash: repaired.commit.artifactHash,
3640
+ isolatedRevisionId: repaired.commit.isolatedRevisionId,
3641
+ outputFacts: repaired.commit.outputFacts,
3642
+ attribution: "introduced",
3643
+ failureIdentities: [
3644
+ hash(
3645
+ "red-artifact-constraint",
3646
+ request.taskId,
3647
+ request.task.phases.red.verification.id,
3648
+ ),
3649
+ ],
3650
+ routeId: repaired.commit.routeId,
3651
+ routeFingerprint: repaired.commit.routeFingerprint,
3652
+ };
3653
+ const greenPaths = new Set([
3654
+ ...request.task.phases.green.write,
3655
+ ...request.task.phases.green.delete,
3656
+ ]);
3657
+ const greenEvent: VerifiedTaskEvent = {
3658
+ runId: request.runId,
3659
+ taskId: request.taskId,
3660
+ eventId: `green-corrected-${hash(
3661
+ request.operationId,
3662
+ request.taskId,
3663
+ repaired.commit.artifactHash,
3664
+ ).slice(0, 40)}`,
3665
+ kind: "phase-verified",
3666
+ phase: "green",
3667
+ commandId: request.task.phases.green.verification.id,
3668
+ exitCode: greenVerification.exitCode,
3669
+ expectedClassification: "expected-green",
3670
+ actualClassification: greenVerification.classification,
3671
+ diagnostic: greenVerification.diagnostic,
3672
+ artifactHash: repaired.commit.artifactHash,
3673
+ isolatedRevisionId: repaired.commit.isolatedRevisionId,
3674
+ outputFacts: repaired.commit.outputFacts.filter((fact) =>
3675
+ greenPaths.has(fact.path),
3676
+ ),
3677
+ routeId: repaired.commit.routeId,
3678
+ routeFingerprint: repaired.commit.routeFingerprint,
3679
+ };
3680
+ const events: VerifiedTaskEvent[] = [repairEvent, greenEvent];
3681
+ if (input.baseline) {
3682
+ const affected = await this.#verifyTaskAffected({
3683
+ resources,
3684
+ baseline: input.baseline,
3685
+ task: request.task,
3686
+ revisionId: repaired.commit.isolatedRevisionId,
3687
+ signal: request.signal,
3688
+ });
3689
+ if (affected.kind === "repairable") {
3690
+ return rejectCorrection({
3691
+ kind: "retryable",
3692
+ code: "red-artifact-constraint",
3693
+ retryPolicy: "artifact",
3694
+ });
3695
+ }
3696
+ if (affected.kind !== "verified") return rejectCorrection(affected);
3697
+ }
3698
+ if (request.task.phases.refactor) {
3699
+ ledger.commitVerifiedEvents(events);
3700
+ return {
3701
+ kind: "phase-committed",
3702
+ artifactHash: repaired.commit.artifactHash,
3703
+ isolatedRevisionId: repaired.commit.isolatedRevisionId,
3704
+ exitCode: greenVerification.exitCode,
3705
+ classification: greenVerification.classification,
3706
+ routeId: repaired.commit.routeId,
3707
+ routeFingerprint: repaired.commit.routeFingerprint,
3708
+ };
3709
+ }
3710
+ const completedRevision = this.#completeTracking(resources, request.taskId);
3711
+ ledger.commitTaskCompletion({
3712
+ events,
3713
+ factKey: `task-final-${hash(request.taskId).slice(0, 40)}`,
3714
+ fact: {
3715
+ taskId: request.taskId,
3716
+ phase: request.phase,
3717
+ isolatedRevisionId: completedRevision.revisionId,
3718
+ attribution: "introduced",
3719
+ repairAttempts: correctionAttemptUsed,
3720
+ },
3721
+ });
3722
+ return {
3723
+ kind: "phase-committed",
3724
+ artifactHash: repaired.commit.artifactHash,
3725
+ isolatedRevisionId: completedRevision.revisionId,
3726
+ exitCode: greenVerification.exitCode,
3727
+ classification: greenVerification.classification,
3728
+ routeId: repaired.commit.routeId,
3729
+ routeFingerprint: repaired.commit.routeFingerprint,
3377
3730
  };
3378
3731
  }
3379
3732
 
@@ -3598,6 +3951,16 @@ class DurableWorkflowComposition
3598
3951
  verificationBaseline = captured.baseline;
3599
3952
  }
3600
3953
  this.#openTask(resources, ledger, input);
3954
+ if (input.artifactCorrection?.code === "red-artifact-constraint") {
3955
+ return retained(
3956
+ await this.#runRedArtifactCorrection({
3957
+ resources,
3958
+ ledger,
3959
+ ...(verificationBaseline ? { baseline: verificationBaseline } : {}),
3960
+ attempt: input,
3961
+ }),
3962
+ );
3963
+ }
3601
3964
  if (input.repair) {
3602
3965
  if (!verificationBaseline) {
3603
3966
  throw new Error("workflow-verification-baseline-unavailable");
@@ -3747,7 +4110,8 @@ class DurableWorkflowComposition
3747
4110
  proposal.kind !== "sealed-candidate"
3748
4111
  ) {
3749
4112
  return proposal.kind === "operation-cancelled" ||
3750
- proposal.kind === "paused"
4113
+ proposal.kind === "paused" ||
4114
+ proposal.kind === "approval-needed"
3751
4115
  ? retained(proposal)
3752
4116
  : reject(proposal, {
3753
4117
  category: "artifact",
@@ -5080,8 +5444,18 @@ export class WorkflowEngine {
5080
5444
  this.#database
5081
5445
  .prepare(
5082
5446
  `UPDATE workflow_engine_tasks
5083
- SET state = 'paused', pause_code = 'operation-interrupted',
5084
- context_request_json = NULL, queue_position = NULL
5447
+ SET state = 'paused',
5448
+ pause_code = CASE
5449
+ WHEN pause_code = 'red-artifact-constraint'
5450
+ THEN pause_code
5451
+ ELSE 'operation-interrupted'
5452
+ END,
5453
+ context_request_json = CASE
5454
+ WHEN pause_code = 'red-artifact-constraint'
5455
+ THEN context_request_json
5456
+ ELSE NULL
5457
+ END,
5458
+ queue_position = NULL
5085
5459
  WHERE run_id = ? AND state IN ('phase-running', 'validating')`,
5086
5460
  )
5087
5461
  .run(row.run_id);
@@ -5745,6 +6119,23 @@ export class WorkflowEngine {
5745
6119
  const orderedPhases = phases(task);
5746
6120
  let phaseIndex = orderedPhases.indexOf(row.phase);
5747
6121
  let artifactAttempts = 0;
6122
+ const persistedContextRequest = row.context_request_json
6123
+ ? normalizeWorkflowContextRequest(
6124
+ JSON.parse(row.context_request_json) as unknown,
6125
+ )
6126
+ : undefined;
6127
+ const persistedRedArtifactCorrection =
6128
+ row.phase === "green" &&
6129
+ persistedContextRequest !== undefined &&
6130
+ classifyPersistedContextRequest(persistedContextRequest, task, "green")
6131
+ .code === "red-artifact-constraint";
6132
+ let redArtifactCorrection =
6133
+ row.phase === "green" &&
6134
+ (row.pause_code === "red-artifact-constraint" ||
6135
+ persistedRedArtifactCorrection);
6136
+ let correctionContextRequest = redArtifactCorrection
6137
+ ? persistedContextRequest
6138
+ : undefined;
5748
6139
  if (phaseIndex < 0) throw new Error("workflow-task-phase-invalid");
5749
6140
  while (phaseIndex < orderedPhases.length) {
5750
6141
  const phase = orderedPhases[phaseIndex];
@@ -5771,6 +6162,7 @@ export class WorkflowEngine {
5771
6162
  JSON.parse(current.verification_json) as unknown,
5772
6163
  )
5773
6164
  : undefined;
6165
+ const plan = this.#planForRevision(runId, row.delivery_revision);
5774
6166
  const repair =
5775
6167
  verificationStatus?.scope === "change-task-affected" &&
5776
6168
  verificationStatus.attribution === "introduced" &&
@@ -5782,19 +6174,31 @@ export class WorkflowEngine {
5782
6174
  failureIdentities: [...verificationStatus.failureIdentities],
5783
6175
  }
5784
6176
  : undefined;
6177
+ const artifactCorrection =
6178
+ redArtifactCorrection && phase === "green"
6179
+ ? {
6180
+ code: "red-artifact-constraint" as const,
6181
+ attempt: artifactAttempts + 1,
6182
+ maxAttempts: plan.verification.artifactCorrection.maxAttempts,
6183
+ ...(correctionContextRequest
6184
+ ? {
6185
+ contextRequest: structuredClone(correctionContextRequest),
6186
+ }
6187
+ : {}),
6188
+ }
6189
+ : undefined;
5785
6190
  this.#setTask(
5786
6191
  runId,
5787
6192
  row.task_id,
5788
6193
  {
5789
6194
  state: "phase-running",
5790
6195
  phase,
5791
- pauseCode: null,
5792
- contextRequest: null,
6196
+ pauseCode: artifactCorrection?.code ?? null,
6197
+ contextRequest: correctionContextRequest ?? null,
5793
6198
  queuePosition: null,
5794
6199
  },
5795
6200
  lease,
5796
6201
  );
5797
- const plan = this.#planForRevision(runId, row.delivery_revision);
5798
6202
  let outcome: WorkflowAttemptOutcome;
5799
6203
  try {
5800
6204
  outcome = await this.#worker.runAttempt({
@@ -5825,6 +6229,7 @@ export class WorkflowEngine {
5825
6229
  }
5826
6230
  : {}),
5827
6231
  ...(repair ? { repair } : {}),
6232
+ ...(artifactCorrection ? { artifactCorrection } : {}),
5828
6233
  signal,
5829
6234
  onActivity: (event) => {
5830
6235
  const projection = this.#runStore.status(runId);
@@ -5864,9 +6269,29 @@ export class WorkflowEngine {
5864
6269
  this.#setVerificationStatus(runId, outcome.verification, lease);
5865
6270
  }
5866
6271
  if (outcome.kind === "retryable" && outcome.retryPolicy === "artifact") {
6272
+ if (outcome.code === "red-artifact-constraint") {
6273
+ redArtifactCorrection = true;
6274
+ correctionContextRequest = outcome.contextRequest
6275
+ ? normalizeWorkflowContextRequest(outcome.contextRequest)
6276
+ : correctionContextRequest;
6277
+ }
5867
6278
  artifactAttempts += 1;
5868
6279
  const maxAttempts = plan.verification.artifactCorrection.maxAttempts;
5869
6280
  if (artifactAttempts < maxAttempts) {
6281
+ this.#setTask(
6282
+ runId,
6283
+ row.task_id,
6284
+ {
6285
+ state: "retryable",
6286
+ phase,
6287
+ pauseCode: redArtifactCorrection
6288
+ ? "red-artifact-constraint"
6289
+ : outcome.code,
6290
+ contextRequest: correctionContextRequest ?? null,
6291
+ queuePosition: null,
6292
+ },
6293
+ lease,
6294
+ );
5870
6295
  emitWorkflowActivity(onActivity, {
5871
6296
  state: "retrying",
5872
6297
  stage: this.#runStore.status(runId).stage,
@@ -5910,6 +6335,8 @@ export class WorkflowEngine {
5910
6335
  return;
5911
6336
  }
5912
6337
  artifactAttempts = 0;
6338
+ redArtifactCorrection = false;
6339
+ correctionContextRequest = undefined;
5913
6340
  phaseIndex += 1;
5914
6341
  if (phaseIndex >= orderedPhases.length) {
5915
6342
  this.#setTask(
@@ -5978,7 +6405,7 @@ export class WorkflowEngine {
5978
6405
  contextRequest:
5979
6406
  !approvalCodeInvalid && outcome.contextRequest
5980
6407
  ? normalizeWorkflowContextRequest(outcome.contextRequest)
5981
- : null,
6408
+ : (correctionContextRequest ?? null),
5982
6409
  queuePosition: null,
5983
6410
  },
5984
6411
  lease,
@@ -5990,11 +6417,119 @@ export class WorkflowEngine {
5990
6417
  }
5991
6418
 
5992
6419
  #firstPaused(rows: EngineTaskRow[]): EngineTaskRow | undefined {
5993
- return rows.find((row) =>
5994
- ["paused", "retryable", "approval-needed"].includes(row.state),
6420
+ return (
6421
+ rows.find((row) => row.state === "approval-needed") ??
6422
+ rows.find((row) => ["paused", "retryable"].includes(row.state))
5995
6423
  );
5996
6424
  }
5997
6425
 
6426
+ #recoverLegacyContextApproval(runId: string): boolean {
6427
+ const projection = this.#runStore.status(runId);
6428
+ if (!["approval-needed", "paused"].includes(projection.state)) return false;
6429
+ const rows = this.#tasks(runId);
6430
+ const approvalRows = rows.filter((row) => row.state === "approval-needed");
6431
+ if (approvalRows.length === 0) return false;
6432
+ const reclassified = rows.flatMap((row) => {
6433
+ if (
6434
+ row.state !== "approval-needed" ||
6435
+ row.pause_code !== "boundary-review-needed" ||
6436
+ row.context_request_json === null
6437
+ ) {
6438
+ return [];
6439
+ }
6440
+ try {
6441
+ const request = normalizeWorkflowContextRequest(
6442
+ JSON.parse(row.context_request_json) as unknown,
6443
+ );
6444
+ const classified = classifyPersistedContextRequest(
6445
+ request,
6446
+ this.#taskPlan(row),
6447
+ row.phase,
6448
+ );
6449
+ if (classified.kind === "approval-needed") return [];
6450
+ return [
6451
+ {
6452
+ row,
6453
+ state: classified.kind === "retryable" ? "retryable" : "paused",
6454
+ code: classified.code,
6455
+ contextRequest: JSON.stringify(
6456
+ normalizeWorkflowContextRequest(classified.contextRequest),
6457
+ ),
6458
+ },
6459
+ ];
6460
+ } catch {
6461
+ return [];
6462
+ }
6463
+ });
6464
+ const reclassifiedTaskIds = new Set(
6465
+ reclassified.map((entry) => entry.row.task_id),
6466
+ );
6467
+ const retainedApprovals = approvalRows.filter(
6468
+ (row) => !reclassifiedTaskIds.has(row.task_id),
6469
+ );
6470
+ const updateTasks = (database: DatabaseSync) => {
6471
+ for (const entry of reclassified) {
6472
+ const updated = database
6473
+ .prepare(
6474
+ `UPDATE workflow_engine_tasks
6475
+ SET state = ?, phase = ?, pause_code = ?, context_request_json = ?,
6476
+ queue_position = NULL
6477
+ WHERE run_id = ? AND task_id = ?
6478
+ AND state = 'approval-needed'
6479
+ AND pause_code = 'boundary-review-needed'`,
6480
+ )
6481
+ .run(
6482
+ entry.state,
6483
+ entry.row.phase,
6484
+ entry.code,
6485
+ entry.contextRequest,
6486
+ runId,
6487
+ entry.row.task_id,
6488
+ );
6489
+ if (Number(updated.changes) !== 1) {
6490
+ throw new Error("workflow-legacy-context-race");
6491
+ }
6492
+ }
6493
+ };
6494
+ const transitionWithUpdates = (
6495
+ to: "paused" | "approval-needed",
6496
+ code: string,
6497
+ ) =>
6498
+ this.#runStore.transitionAtomically(
6499
+ {
6500
+ runId,
6501
+ to,
6502
+ operationId: `legacy-context-${hash(
6503
+ runId,
6504
+ String(projection.sequence),
6505
+ to,
6506
+ code,
6507
+ JSON.stringify(
6508
+ reclassified.map((entry) => [entry.row.task_id, entry.code]),
6509
+ ),
6510
+ ).slice(0, 40)}`,
6511
+ code,
6512
+ },
6513
+ updateTasks,
6514
+ );
6515
+ if (retainedApprovals.length > 0) {
6516
+ if (projection.state === "approval-needed") {
6517
+ if (reclassified.length === 0) return false;
6518
+ this.#transaction(() => updateTasks(this.#database));
6519
+ } else {
6520
+ transitionWithUpdates(
6521
+ "approval-needed",
6522
+ retainedApprovals[0].pause_code ?? "approval-code-invalid",
6523
+ );
6524
+ }
6525
+ return true;
6526
+ }
6527
+ const first = reclassified[0];
6528
+ if (!first) return false;
6529
+ transitionWithUpdates("paused", first.code);
6530
+ return true;
6531
+ }
6532
+
5998
6533
  async #finishChange(
5999
6534
  runId: string,
6000
6535
  operationId: string,
@@ -6592,7 +7127,10 @@ export class WorkflowEngine {
6592
7127
  )
6593
7128
  : undefined;
6594
7129
  const pauseCode =
6595
- projection.pauseCode ?? firstPaused?.pause_code ?? "task-paused";
7130
+ projection.state === "approval-needed" &&
7131
+ firstPaused?.state === "approval-needed"
7132
+ ? (firstPaused.pause_code ?? projection.pauseCode ?? "task-paused")
7133
+ : (projection.pauseCode ?? firstPaused?.pause_code ?? "task-paused");
6596
7134
  const approval =
6597
7135
  projection.state === "approval-needed"
6598
7136
  ? approvalRequirement(pauseCode, contextRequest)
@@ -6941,6 +7479,7 @@ export class WorkflowEngine {
6941
7479
  ) {
6942
7480
  const runId = this.#lookupRun(command.stage, command.change);
6943
7481
  if (!runId) throw new Error("run-not-found");
7482
+ this.#recoverLegacyContextApproval(runId);
6944
7483
  const replay = this.#beginOperation(
6945
7484
  runId,
6946
7485
  command.operationId,
@@ -7337,6 +7876,7 @@ export class WorkflowEngine {
7337
7876
  case "status": {
7338
7877
  const runId = this.#lookupRun(command.stage, command.change);
7339
7878
  if (!runId) throw new Error("run-not-found");
7879
+ this.#recoverLegacyContextApproval(runId);
7340
7880
  return this.#withAvailableDelivery(this.#statusByRun(runId));
7341
7881
  }
7342
7882
  case "resume":