@abelxiaoxing/cadence 1.2.0 → 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.
@@ -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":