@abelxiaoxing/cadence 1.4.0 → 1.4.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.
Files changed (49) hide show
  1. package/README.md +58 -4
  2. package/agents/design-explorer.md +17 -11
  3. package/agents/diagnosis-worker.md +9 -1
  4. package/agents/implementation-worker.md +3 -1
  5. package/config/plan-draft.example.json +122 -0
  6. package/config/plan-draft.multiple-tasks.example.json +345 -0
  7. package/package.json +5 -3
  8. package/prompts/abel-design.md +93 -44
  9. package/src/activity-contracts.ts +82 -0
  10. package/src/candidate-artifact.ts +143 -0
  11. package/src/candidate-patch.ts +31 -11
  12. package/src/candidate-workspace.ts +74 -0
  13. package/src/change-contract-schema.ts +191 -0
  14. package/src/change-contract.ts +5 -0
  15. package/src/change-verification.ts +896 -0
  16. package/src/child-model.ts +129 -0
  17. package/src/child-session.ts +293 -326
  18. package/src/contracts.ts +184 -76
  19. package/src/delivery-compiler.ts +103 -120
  20. package/src/delivery-revision.ts +42 -0
  21. package/src/design-control.ts +113 -4
  22. package/src/design-diagnostics.ts +150 -0
  23. package/src/durable-contracts.ts +210 -0
  24. package/src/durable-workflow.ts +148 -3570
  25. package/src/evidence-draft.ts +122 -0
  26. package/src/implement-graph.ts +3 -0
  27. package/src/implement-plan.ts +70 -0
  28. package/src/index.ts +59 -522
  29. package/src/model-source.ts +22 -0
  30. package/src/package-candidate.ts +224 -0
  31. package/src/package-workflow.ts +253 -0
  32. package/src/packet-runtime.ts +27 -48
  33. package/src/parent-provider.ts +86 -198
  34. package/src/phase-execution.ts +2248 -0
  35. package/src/pi-adapter.ts +19 -0
  36. package/src/plan-draft-summary.ts +146 -0
  37. package/src/plan-draft.ts +493 -0
  38. package/src/subagent-activity.ts +32 -66
  39. package/src/submit-tool.ts +20 -59
  40. package/src/transport-budget.ts +32 -0
  41. package/src/worker-broker.ts +37 -57
  42. package/src/workflow-policy.ts +14 -53
  43. package/src/workflow-recovery-policy.ts +95 -0
  44. package/src/workflow-scheduling.ts +105 -0
  45. package/src/workflow-state-machine.ts +105 -497
  46. package/src/workflow-status.ts +384 -0
  47. package/src/workspace-io-worker.mjs +7 -0
  48. package/src/empty-resource-loader.ts +0 -38
  49. package/src/parent-payload-bridge.ts +0 -523
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
+ import type { WorkflowActivityUpdate } from "./activity-contracts.ts";
4
5
  import {
5
6
  assertPlanWithinChangeContract,
6
7
  normalizeChangeContract,
@@ -18,11 +19,11 @@ import {
18
19
  type ImplementPlan,
19
20
  type PlanTaskDraft,
20
21
  } from "./delivery-compiler.ts";
22
+ import { compareDeliveryRevision } from "./delivery-revision.ts";
21
23
  import { snapshotFiles } from "./file-snapshot.ts";
22
24
  import type { RoutePolicy } from "./route-policy.ts";
23
25
  import {
24
26
  canonicalJson,
25
- legalControlCommands,
26
27
  type RunProjection,
27
28
  type RunState,
28
29
  } from "./run-state.ts";
@@ -39,11 +40,9 @@ import {
39
40
  RECOVERY_SCHEMA,
40
41
  REJECTED_DELIVERY_SCHEMA,
41
42
  } from "./storage-schema.ts";
42
- import type { WorkflowActivityUpdate } from "./subagent-activity.ts";
43
43
  import { permitsContextRead } from "./submit-tool.ts";
44
44
  import {
45
45
  type ActiveOperation,
46
- approvalRequirement,
47
46
  assertDelivery,
48
47
  type BootstrapRow,
49
48
  bootstrapAcceptanceHash,
@@ -51,7 +50,6 @@ import {
51
50
  cancellableRead,
52
51
  classifyPersistedContextRequest,
53
52
  decideRecoveryAction,
54
- deliveryBoundPaths,
55
53
  type EngineOperationRow,
56
54
  type EngineRunRow,
57
55
  type EngineTaskRow,
@@ -66,16 +64,12 @@ import {
66
64
  normalizeBootstrapAcceptanceFacts,
67
65
  normalizeWorkflowContextRequest,
68
66
  normalizeWorkflowVerificationStatus,
69
- parseDeliveryDiagnostics,
70
67
  parseJsonRecord,
71
68
  parsePlan,
72
- permitsPlanAmendment,
73
69
  phases,
74
70
  recoveryKey,
75
71
  type SafeAttemptDiagnostic,
76
72
  SHA256,
77
- taskApprovalBoundary,
78
- taskConflicts,
79
73
  type WorkflowApplication,
80
74
  type WorkflowApplicationContext,
81
75
  type WorkflowAttemptOutcome,
@@ -90,6 +84,16 @@ import {
90
84
  type WorkflowVerificationStatus,
91
85
  type WorkflowWorker,
92
86
  } from "./workflow-policy.ts";
87
+ import {
88
+ assessRecoveryGrant,
89
+ recoveryExhausted,
90
+ } from "./workflow-recovery-policy.ts";
91
+ import { selectRunnableTasks } from "./workflow-scheduling.ts";
92
+ import {
93
+ firstPausedTask,
94
+ projectWorkflowStatus,
95
+ type WorkflowResourceBudget,
96
+ } from "./workflow-status.ts";
93
97
  export class WorkflowEngine {
94
98
  readonly #consumerRoot: string;
95
99
  readonly #stateRoot: ResolvedStateRoot;
@@ -948,36 +952,13 @@ export class WorkflowEngine {
948
952
  engineRun.current_revision === null
949
953
  ? undefined
950
954
  : this.#planForRevision(runId, engineRun.current_revision);
951
- const priorTasks = new Map(
952
- (priorPlan?.tasks ?? []).map((task) => [task.taskId, task]),
953
- );
954
- const nextTasks = new Map(
955
- delivery.plan.tasks.map((task) => [task.taskId, task]),
956
- );
957
- const priorBoundPaths = new Set(
958
- priorPlan ? deliveryBoundPaths(priorPlan) : [],
959
- );
960
- const boundaryExpanded = deliveryBoundPaths(delivery.plan).some(
961
- (relative) => !priorBoundPaths.has(relative),
962
- );
963
- const compatible = new Set<string>();
964
- if (priorPlan) {
965
- for (const [taskId, priorTask] of priorTasks) {
966
- const nextTask = nextTasks.get(taskId);
967
- if (
968
- nextTask &&
969
- taskApprovalBoundary(priorPlan, priorTask) ===
970
- taskApprovalBoundary(delivery.plan, nextTask)
971
- ) {
972
- compatible.add(taskId);
973
- }
974
- }
975
- }
976
- let invalidated = new Set(
977
- priorRows
978
- .filter((row) => !compatible.has(row.task_id))
979
- .map((row) => row.task_id),
955
+ const comparison = compareDeliveryRevision(
956
+ priorPlan,
957
+ delivery.plan,
958
+ priorRows,
980
959
  );
960
+ const { compatible, boundaryExpanded } = comparison;
961
+ let invalidated = comparison.invalidated;
981
962
  let revalidatedWorkspace:
982
963
  | {
983
964
  baselineRevisionId: string;
@@ -1416,62 +1397,6 @@ export class WorkflowEngine {
1416
1397
  });
1417
1398
  }
1418
1399
 
1419
- #hasConflict(
1420
- row: EngineTaskRow,
1421
- rows: EngineTaskRow[],
1422
- selectedTaskIds: ReadonlySet<string>,
1423
- ): boolean {
1424
- const candidate = this.#taskPlan(row);
1425
- return rows.some((other) => {
1426
- if (other.task_id === row.task_id || other.state === "verified") {
1427
- return false;
1428
- }
1429
- if (other.state === "queued") {
1430
- if (row.state !== "queued") return false;
1431
- const candidatePosition = row.queue_position ?? Number.MAX_SAFE_INTEGER;
1432
- const otherPosition = other.queue_position ?? Number.MAX_SAFE_INTEGER;
1433
- if (
1434
- otherPosition > candidatePosition ||
1435
- (otherPosition === candidatePosition &&
1436
- other.task_order > row.task_order)
1437
- ) {
1438
- return false;
1439
- }
1440
- }
1441
- if (other.task_order > row.task_order && other.state === "pending") {
1442
- return false;
1443
- }
1444
- if (
1445
- other.state === "pending" &&
1446
- !this.#dependenciesVerified(other, rows)
1447
- ) {
1448
- return false;
1449
- }
1450
- const conflict = taskConflicts(candidate, this.#taskPlan(other));
1451
- return (
1452
- conflict.taskLifetime ||
1453
- (conflict.verification &&
1454
- ([
1455
- "pending",
1456
- "phase-ready",
1457
- "phase-running",
1458
- "validating",
1459
- "queued",
1460
- ].includes(other.state) ||
1461
- selectedTaskIds.has(other.task_id)))
1462
- );
1463
- });
1464
- }
1465
-
1466
- #dependenciesVerified(row: EngineTaskRow, rows: EngineTaskRow[]): boolean {
1467
- const byId = new Map(
1468
- rows.map((candidate) => [candidate.task_id, candidate]),
1469
- );
1470
- return this.#taskPlan(row).dependsOn.every(
1471
- (dependency) => byId.get(dependency)?.state === "verified",
1472
- );
1473
- }
1474
-
1475
1400
  #recordWorkspaceFacts(
1476
1401
  runId: string,
1477
1402
  outcome: WorkflowAttemptOutcome,
@@ -1662,9 +1587,7 @@ export class WorkflowEngine {
1662
1587
  recoveryKey(task, phase, row, current),
1663
1588
  );
1664
1589
  const verificationOnly =
1665
- !granted &&
1666
- activeRecovery &&
1667
- activeRecovery.failures >= maxRecoveryAttempts &&
1590
+ recoveryExhausted(activeRecovery, maxRecoveryAttempts, granted) &&
1668
1591
  (await this.#worker.hasPendingVerification?.({
1669
1592
  runId,
1670
1593
  operationId,
@@ -1682,10 +1605,9 @@ export class WorkflowEngine {
1682
1605
  : {}),
1683
1606
  })) === true;
1684
1607
  if (
1685
- !granted &&
1686
1608
  !verificationOnly &&
1687
1609
  activeRecovery &&
1688
- activeRecovery.failures >= maxRecoveryAttempts
1610
+ recoveryExhausted(activeRecovery, maxRecoveryAttempts, granted)
1689
1611
  ) {
1690
1612
  this.#setTask(
1691
1613
  runId,
@@ -2141,10 +2063,7 @@ export class WorkflowEngine {
2141
2063
  }
2142
2064
 
2143
2065
  #firstPaused(rows: EngineTaskRow[]): EngineTaskRow | undefined {
2144
- return (
2145
- rows.find((row) => row.state === "approval-needed") ??
2146
- rows.find((row) => ["paused", "retryable"].includes(row.state))
2147
- );
2066
+ return firstPausedTask(rows);
2148
2067
  }
2149
2068
 
2150
2069
  #recoverLegacyContextApproval(runId: string): boolean {
@@ -2791,46 +2710,34 @@ export class WorkflowEngine {
2791
2710
  while (madeProgress && !signal.aborted) {
2792
2711
  madeProgress = false;
2793
2712
  const rows = this.#tasks(runId);
2794
- const runnable: Array<{
2795
- row: EngineTaskRow;
2796
- before: string;
2797
- }> = [];
2798
- const selectedTaskIds = new Set<string>();
2799
- let capacityBlocked = false;
2800
- for (const row of rows) {
2801
- if (row.state === "verified" || attempted.has(row.task_id)) continue;
2802
- if (!this.#dependenciesVerified(row, rows)) {
2803
- if (row.state !== "pending") {
2804
- this.#setTask(
2805
- runId,
2806
- row.task_id,
2807
- {
2808
- state: "pending",
2809
- pauseCode: null,
2810
- queuePosition: null,
2811
- },
2812
- lease,
2813
- );
2814
- }
2815
- continue;
2816
- }
2817
- if (this.#hasConflict(row, this.#tasks(runId), selectedTaskIds)) {
2818
- this.#queueTask(runId, row, lease);
2819
- continue;
2820
- }
2821
- if (
2822
- this.#activeTasks + runnable.length >=
2823
- LIMITS.maxActiveChildSessions
2824
- ) {
2825
- this.#queueTask(runId, row, lease, "capacity");
2826
- capacityBlocked = true;
2827
- continue;
2828
- }
2829
- const before = `${row.state}:${row.phase}`;
2830
- attempted.add(row.task_id);
2831
- selectedTaskIds.add(row.task_id);
2832
- runnable.push({ row, before });
2713
+ const selected = selectRunnableTasks({
2714
+ rows,
2715
+ attempted,
2716
+ activeTasks: this.#activeTasks,
2717
+ capacity: LIMITS.maxActiveChildSessions,
2718
+ nextQueuePosition: this.#engineRun(runId).next_queue_position,
2719
+ });
2720
+ const runnable = selected.runnable.map((taskId) => {
2721
+ const row = rows.find((candidate) => candidate.task_id === taskId);
2722
+ if (!row) throw new Error("scheduler-task-unavailable");
2723
+ attempted.add(taskId);
2724
+ return { row, before: `${row.state}:${row.phase}` };
2725
+ });
2726
+ for (const update of selected.updates) {
2727
+ const row = rows.find(
2728
+ (candidate) => candidate.task_id === update.taskId,
2729
+ );
2730
+ if (!row) throw new Error("scheduler-task-unavailable");
2731
+ if (update.kind === "pending")
2732
+ this.#setTask(
2733
+ runId,
2734
+ row.task_id,
2735
+ { state: "pending", pauseCode: null, queuePosition: null },
2736
+ lease,
2737
+ );
2738
+ else this.#queueTask(runId, row, lease, update.reason);
2833
2739
  }
2740
+ const capacityBlocked = selected.capacityBlocked;
2834
2741
  if (runnable.length === 0 && capacityBlocked) {
2835
2742
  await this.#waitForCapacity(signal);
2836
2743
  madeProgress = true;
@@ -3019,20 +2926,22 @@ export class WorkflowEngine {
3019
2926
  .get(runId, request.incidentKey) as { conditions_json: string };
3020
2927
  const previous = JSON.parse(conditions.conditions_json);
3021
2928
  const observed = this.#recoveryConditions(runId, request.incidentKey);
3022
- if (
3023
- (request.reason === "route-changed" &&
3024
- (!previous.route || previous.route === observed.route)) ||
3025
- (request.reason === "context-extended" &&
3026
- (!previous.context || previous.context === observed.context))
3027
- )
3028
- throw new Error("recovery-evidence-unavailable");
3029
2929
  const budget = this.#database
3030
2930
  .prepare(
3031
2931
  "SELECT used, max_work FROM workflow_work_budget WHERE run_id = ?",
3032
2932
  )
3033
2933
  .get(runId) as { used: number; max_work: number };
3034
- if (!budget || budget.used >= budget.max_work)
3035
- throw new Error("change-work-budget-exhausted");
2934
+ const rejected = assessRecoveryGrant({
2935
+ current,
2936
+ incident,
2937
+ failureSequence: this.#failureSequence(runId, request.incidentKey),
2938
+ requestedSequence: request.failureSequence,
2939
+ reason: request.reason,
2940
+ previous,
2941
+ observed,
2942
+ budget,
2943
+ });
2944
+ if (rejected) throw new Error(rejected);
3036
2945
  this.#database
3037
2946
  .prepare(
3038
2947
  "INSERT INTO workflow_recovery_grants(run_id, operation_id, incident_key, failure_sequence, reason) VALUES (?, ?, ?, ?, ?)",
@@ -3147,100 +3056,22 @@ export class WorkflowEngine {
3147
3056
  #statusByRun(runId: string): Record<string, unknown> {
3148
3057
  const projection = this.#runStore.status(runId);
3149
3058
  const rows = this.#tasks(runId);
3150
- const queued = rows
3151
- .filter((row) => row.state === "queued")
3152
- .sort(
3153
- (left, right) =>
3154
- (left.queue_position ?? Number.MAX_SAFE_INTEGER) -
3155
- (right.queue_position ?? Number.MAX_SAFE_INTEGER) ||
3156
- left.task_order - right.task_order,
3157
- );
3158
- const firstPaused = this.#firstPaused(rows);
3159
3059
  let engineRun: EngineRunRow | undefined;
3160
3060
  try {
3161
3061
  engineRun = this.#engineRun(runId);
3162
3062
  } catch {
3163
3063
  engineRun = undefined;
3164
3064
  }
3165
- const contextRequest = firstPaused?.context_request_json
3166
- ? normalizeWorkflowContextRequest(
3167
- JSON.parse(firstPaused.context_request_json) as unknown,
3168
- )
3169
- : undefined;
3170
- const pauseCode =
3171
- projection.state === "approval-needed" &&
3172
- firstPaused?.state === "approval-needed"
3173
- ? (firstPaused.pause_code ?? projection.pauseCode ?? "task-paused")
3174
- : (projection.pauseCode ?? firstPaused?.pause_code ?? "task-paused");
3175
- const approval =
3176
- projection.state === "approval-needed"
3177
- ? approvalRequirement(pauseCode, contextRequest)
3178
- : undefined;
3179
- const attemptDiagnostic = this.#safeAttemptDiagnostic(runId, firstPaused);
3180
- const recovery =
3181
- rows
3182
- .map((row) => this.#safeAttemptDiagnostic(runId, row)?.recovery)
3183
- .find((fact) => fact && fact.failures >= fact.feedback.maxAttempts) ??
3184
- attemptDiagnostic?.recovery;
3185
- const recoveryExhausted =
3186
- recovery && recovery.failures >= recovery.feedback.maxAttempts;
3187
- const blockers = rows
3188
- .filter((row) =>
3189
- ["approval-needed", "paused", "retryable"].includes(row.state),
3190
- )
3191
- .map((row) => {
3192
- const context = row.context_request_json
3193
- ? normalizeWorkflowContextRequest(
3194
- JSON.parse(row.context_request_json),
3195
- )
3196
- : undefined;
3197
- const code = row.pause_code ?? "task-paused";
3198
- const requirement =
3199
- row.state === "approval-needed"
3200
- ? approvalRequirement(code, context)
3201
- : undefined;
3202
- return {
3203
- id: hash(runId, row.task_id, code, canonicalJson(context ?? {})),
3204
- taskId: row.task_id,
3205
- phase: row.phase,
3206
- code,
3207
- kind: requirement ? "decision" : "execution",
3208
- ...(requirement ?? {}),
3209
- ...(context ? { contextRequest: context } : {}),
3210
- };
3211
- });
3212
- if (
3213
- projection.state === "approval-needed" &&
3214
- !blockers.some((item) => item.kind === "decision") &&
3215
- approval
3216
- ) {
3217
- blockers.push({
3218
- id: hash(runId, pauseCode),
3219
- taskId: "change",
3220
- phase: "green",
3221
- code: pauseCode,
3222
- kind: "decision",
3223
- ...approval,
3224
- });
3225
- }
3226
- const decisions = blockers.filter((item) => item.kind === "decision");
3227
- const amendments = blockers.filter(
3228
- (item) => item.kind === "decision" || permitsPlanAmendment(item.code),
3229
- );
3230
- if (
3231
- projection.state === "paused" &&
3232
- permitsPlanAmendment(pauseCode) &&
3233
- !amendments.some((item) => item.code === pauseCode)
3234
- ) {
3235
- const item = {
3236
- id: hash(runId, pauseCode, engineRun?.delivery_diagnostics_json ?? ""),
3237
- taskId: "change",
3238
- phase: "red" as const,
3239
- code: pauseCode,
3240
- kind: "execution",
3241
- };
3242
- blockers.push(item);
3243
- amendments.push(item);
3065
+ const attemptDiagnostics = new Map<string, SafeAttemptDiagnostic>();
3066
+ const failureSequences = new Map<string, number>();
3067
+ for (const row of rows) {
3068
+ const diagnostic = this.#safeAttemptDiagnostic(runId, row);
3069
+ if (!diagnostic) continue;
3070
+ attemptDiagnostics.set(row.task_id, diagnostic);
3071
+ if (diagnostic.recovery) {
3072
+ const key = diagnostic.recovery.key;
3073
+ failureSequences.set(key, this.#failureSequence(runId, key));
3074
+ }
3244
3075
  }
3245
3076
  const amendmentUsed =
3246
3077
  (
@@ -3250,254 +3081,20 @@ export class WorkflowEngine {
3250
3081
  )
3251
3082
  .get(runId) as { used: number } | undefined
3252
3083
  )?.used ?? 0;
3253
- const amendmentBudget = {
3254
- used: amendmentUsed,
3255
- maximum: 64,
3256
- remaining: 64 - amendmentUsed,
3257
- exhausted: amendmentUsed >= 64,
3258
- };
3259
- const decisionBatch =
3260
- amendments.length &&
3261
- pauseCode !== "operation-cancelled" &&
3262
- ["approval-needed", "paused"].includes(projection.state)
3263
- ? {
3264
- id: hash(
3265
- runId,
3266
- String(projection.deliveryRevision ?? 0),
3267
- canonicalJson(amendments),
3268
- ),
3269
- continuation: {
3270
- action: "amend",
3271
- change: projection.change,
3272
- batchId: hash(
3273
- runId,
3274
- String(projection.deliveryRevision ?? 0),
3275
- canonicalJson(amendments),
3276
- ),
3277
- },
3278
- resolution: {
3279
- owner: "parent",
3280
- strategy: "recommended",
3281
- requiresUserInput: false,
3282
- },
3283
- items: amendments,
3284
- requiredGates: [
3285
- ...new Set(
3286
- amendments.flatMap((item) => item.requiredGates ?? ["gate-b"]),
3287
- ),
3288
- ].sort(),
3289
- }
3290
- : undefined;
3291
3084
  const resourceBudget = this.#database
3292
3085
  .prepare(
3293
3086
  "SELECT used, max_work AS maximum, phase_high_water AS phaseHighWater, hard_limit AS hardLimit FROM workflow_work_budget WHERE run_id = ?",
3294
3087
  )
3295
- .get(runId) as { used: number; maximum: number } | undefined;
3296
- const budgetCode = rows.find(
3297
- (row) =>
3298
- row.pause_code === "change-work-budget-exhausted" ||
3299
- row.pause_code === "change-recovery-budget-exhausted",
3300
- )?.pause_code;
3301
- const budgetExhausted = budgetCode !== undefined;
3302
- const currentRevision = projection.deliveryRevision ?? 0;
3303
- return {
3304
- runId: projection.runId,
3305
- stage: projection.stage,
3306
- ...(projection.change ? { change: projection.change } : {}),
3307
- state: projection.state,
3308
- ...(projection.deliveryRevision === undefined
3309
- ? {}
3310
- : { deliveryRevision: projection.deliveryRevision }),
3311
- ...(projection.deliveryBindings.length > 0
3312
- ? { deliveryBindings: structuredClone(projection.deliveryBindings) }
3313
- : {}),
3314
- completed: projection.state === "completed",
3315
- blockers,
3316
- ...(resourceBudget
3317
- ? {
3318
- resourceBudget: {
3319
- ...resourceBudget,
3320
- remaining: resourceBudget.maximum - resourceBudget.used,
3321
- },
3322
- }
3323
- : {}),
3324
- ...(budgetExhausted
3325
- ? {
3326
- recovery: {
3327
- automatic: false,
3328
- exhausted: true,
3329
- code: budgetCode,
3330
- },
3331
- }
3332
- : {}),
3333
- ...(decisionBatch
3334
- ? {
3335
- decisionBatch,
3336
- amendmentBudget,
3337
- ...(!amendmentBudget.exhausted &&
3338
- !budgetExhausted &&
3339
- (!resourceBudget || resourceBudget.used < resourceBudget.maximum)
3340
- ? {
3341
- continuation: {
3342
- owner: "parent",
3343
- automatic: true,
3344
- ...decisionBatch.continuation,
3345
- },
3346
- }
3347
- : {}),
3348
- }
3349
- : {}),
3350
- ...(recoveryExhausted
3351
- ? {
3352
- recovery: {
3353
- automatic: false,
3354
- exhausted: true,
3355
- code: recovery.feedback.code,
3356
- attempts: recovery.failures,
3357
- maxAttempts: recovery.feedback.maxAttempts,
3358
- automaticRetryExhausted: true,
3359
- ...(!budgetExhausted &&
3360
- resourceBudget &&
3361
- resourceBudget.used < resourceBudget.maximum
3362
- ? {
3363
- additionalAttempt: {
3364
- incidentKey: recovery.key,
3365
- failureSequence: this.#failureSequence(
3366
- runId,
3367
- recovery.key,
3368
- ),
3369
- reason: "parent-directed-retry",
3370
- },
3371
- }
3372
- : {}),
3373
- },
3374
- }
3375
- : {}),
3376
- ...(projection.terminal ? { terminal: projection.terminal } : {}),
3377
- ...(projection.pauseCode || firstPaused?.pause_code
3378
- ? {
3379
- pause: {
3380
- code: pauseCode,
3381
- ...(attemptDiagnostic ? { diagnostic: attemptDiagnostic } : {}),
3382
- },
3383
- }
3384
- : {}),
3385
- legalCommands:
3386
- projection.state === "approval-needed"
3387
- ? ["status", "discard"]
3388
- : legalControlCommands(projection.state).filter(
3389
- (command) =>
3390
- (command !== "rebind" || firstPaused !== undefined) &&
3391
- (command !== "resume" ||
3392
- (!recoveryExhausted && !budgetExhausted)),
3393
- ),
3394
- ...(approval && projection.change
3395
- ? {
3396
- approval: {
3397
- category: approval.category,
3398
- requiredGates:
3399
- decisionBatch?.requiredGates ?? approval.requiredGates,
3400
- categories: [
3401
- ...new Set(
3402
- decisions.flatMap((item) =>
3403
- item.category ? [item.category] : [],
3404
- ),
3405
- ),
3406
- ],
3407
- refs: [...new Set(decisions.flatMap((item) => item.refs ?? []))],
3408
- continuation: decisionBatch?.continuation,
3409
- retainedRun: {
3410
- runId: projection.runId,
3411
- deliveryRevision: currentRevision,
3412
- },
3413
- receiptPrecondition: {
3414
- deliveryRevision: { greaterThan: currentRevision },
3415
- receiptHash: "matching-ready-receipt",
3416
- },
3417
- },
3418
- conditionalCommands: [
3419
- {
3420
- command: "resume",
3421
- stage: "abel-implement",
3422
- change: projection.change,
3423
- requires: {
3424
- deliveryRevision: { greaterThan: currentRevision },
3425
- receiptHash: "matching-ready-receipt",
3426
- },
3427
- },
3428
- ],
3429
- }
3430
- : {}),
3431
- tasks: rows.map((row) => ({
3432
- taskId: row.task_id,
3433
- state: row.state,
3434
- ...(row.state === "verified" ? {} : { phase: row.phase }),
3435
- ...(row.context_request_json
3436
- ? {
3437
- contextRequest: normalizeWorkflowContextRequest(
3438
- JSON.parse(row.context_request_json) as unknown,
3439
- ),
3440
- }
3441
- : {}),
3442
- })),
3443
- queue: queued.map((row, index) => ({
3444
- taskId: row.task_id,
3445
- position: index + 1,
3446
- reason: row.pause_code === "worker-capacity" ? "capacity" : "conflict",
3447
- })),
3448
- ...(engineRun?.verification_json
3449
- ? {
3450
- verification: normalizeWorkflowVerificationStatus(
3451
- JSON.parse(engineRun.verification_json) as unknown,
3452
- ),
3453
- }
3454
- : {}),
3455
- ...(engineRun?.delivery_diagnostics_json
3456
- ? {
3457
- delivery: {
3458
- code: "delivery-invalid",
3459
- diagnostics: parseDeliveryDiagnostics(
3460
- engineRun.delivery_diagnostics_json,
3461
- ),
3462
- },
3463
- }
3464
- : {}),
3465
- ...(engineRun?.route_id
3466
- ? {
3467
- routeBinding: {
3468
- routeId: engineRun.route_id,
3469
- ...(engineRun.route_fingerprint
3470
- ? { routeFingerprint: engineRun.route_fingerprint }
3471
- : {}),
3472
- },
3473
- }
3474
- : {}),
3475
- ...(engineRun?.cleanup_state === "retained"
3476
- ? {
3477
- privateData: {
3478
- retained: true,
3479
- cleanup: "retained",
3480
- ...(engineRun.baseline_workspace_revision
3481
- ? {
3482
- baselineRevisionId: engineRun.baseline_workspace_revision,
3483
- }
3484
- : {}),
3485
- ...(engineRun.current_workspace_revision
3486
- ? {
3487
- currentRevisionId: engineRun.current_workspace_revision,
3488
- }
3489
- : {}),
3490
- },
3491
- }
3492
- : engineRun?.cleanup_state === "complete"
3493
- ? {
3494
- privateData: {
3495
- retained: false,
3496
- cleanup: "complete",
3497
- },
3498
- }
3499
- : {}),
3500
- };
3088
+ .get(runId) as WorkflowResourceBudget | undefined;
3089
+ return projectWorkflowStatus({
3090
+ projection,
3091
+ rows,
3092
+ engineRun,
3093
+ attemptDiagnostics,
3094
+ failureSequences,
3095
+ amendmentUsed,
3096
+ resourceBudget,
3097
+ });
3501
3098
  }
3502
3099
 
3503
3100
  async #withAvailableDelivery(
@@ -3896,21 +3493,6 @@ export class WorkflowEngine {
3896
3493
  signal,
3897
3494
  );
3898
3495
  this.#admitDelivery(runId, delivery, lease);
3899
- this.#transition(
3900
- runId,
3901
- "ready",
3902
- `${command.operationId}:delivery-ready`,
3903
- undefined,
3904
- lease,
3905
- );
3906
- } else if (current.state !== "ready") {
3907
- this.#transition(
3908
- runId,
3909
- "ready",
3910
- `${command.operationId}:resume-ready`,
3911
- undefined,
3912
- lease,
3913
- );
3914
3496
  }
3915
3497
  if (command.recovery)
3916
3498
  this.#grantRecovery(
@@ -3919,6 +3501,13 @@ export class WorkflowEngine {
3919
3501
  command.recovery,
3920
3502
  lease,
3921
3503
  );
3504
+ this.#transition(
3505
+ runId,
3506
+ "ready",
3507
+ `${command.operationId}:resume-ready`,
3508
+ undefined,
3509
+ lease,
3510
+ );
3922
3511
  return this.#launchAdvance(
3923
3512
  runId,
3924
3513
  command.operationId,
@@ -3943,6 +3532,25 @@ export class WorkflowEngine {
3943
3532
  if (failure) {
3944
3533
  return this.#commitOperation(runId, command.operationId, failure);
3945
3534
  }
3535
+ // A revised delivery may invalidate a grant. Retain its admitted facts,
3536
+ // but never leave a rejected resume looking ready or still validating.
3537
+ if (
3538
+ error instanceof Error &&
3539
+ [
3540
+ "recovery-request-stale",
3541
+ "recovery-evidence-unavailable",
3542
+ "change-work-budget-exhausted",
3543
+ ].includes(error.message) &&
3544
+ this.#runStore.status(runId).state === "validating-delivery"
3545
+ ) {
3546
+ this.#transition(
3547
+ runId,
3548
+ "paused",
3549
+ `${command.operationId}:recovery-rejected`,
3550
+ error.message,
3551
+ lease,
3552
+ );
3553
+ }
3946
3554
  this.#interruptOperation(runId, command.operationId);
3947
3555
  throw error;
3948
3556
  }