@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
@@ -12,8 +12,11 @@ import {
12
12
  import type {
13
13
  PacketActivityEvent,
14
14
  PacketActivityObserver,
15
- PacketActivityState,
16
- } from "./packet-runtime.ts";
15
+ } from "./activity-contracts.ts";
16
+ import {
17
+ projectDesignDiagnostic,
18
+ type SafeDesignDiagnostic,
19
+ } from "./design-diagnostics.ts";
17
20
 
18
21
  export const ACTIVITY_DETAILS_KEY = "activityDisplay" as const;
19
22
  export const ACTIVITY_WIDGET_KEY = "abel-subagents" as const;
@@ -22,51 +25,20 @@ export const ACTIVITY_REFRESH_MS = 100;
22
25
  export const ACTIVITY_WIDGET_MAX_LINES = 12;
23
26
  export const ACTIVITY_WIDGET_MIN_WIDTH = 3;
24
27
 
25
- export const WORKFLOW_ACTIVITY_STATES = [
26
- "queued",
27
- "connecting",
28
- "waiting-first-response",
29
- "running",
30
- "validating",
31
- "retrying",
32
- "verifying",
33
- "paused",
34
- "approval-needed",
35
- "applying",
36
- "recovering",
37
- "operation-cancelled",
38
- "discarded",
39
- "rejected",
40
- "completed",
41
- ] as const;
42
-
43
- export type WorkflowActivityState = (typeof WORKFLOW_ACTIVITY_STATES)[number];
44
- export type ActivityState = PacketActivityState | WorkflowActivityState;
45
- export type TerminalActivityState =
46
- | Exclude<
47
- PacketActivityState,
48
- | "queued"
49
- | "connecting"
50
- | "waiting-first-response"
51
- | "running"
52
- | "retrying"
53
- >
54
- | WorkflowActivityState;
55
-
56
- export interface WorkflowActivityUpdate {
57
- state: WorkflowActivityState;
58
- stage?: "abel-design" | "abel-implement";
59
- runId?: string;
60
- change?: string;
61
- taskId?: string;
62
- phase?: string;
63
- objective?: string;
64
- code?: string;
65
- attempt?: number;
66
- maxAttempts?: number;
67
- wait?: string;
68
- legalCommands?: string[];
69
- }
28
+ export type {
29
+ ActivityState,
30
+ TerminalActivityState,
31
+ WorkflowActivityState,
32
+ WorkflowActivityUpdate,
33
+ } from "./activity-contracts.ts";
34
+ export { WORKFLOW_ACTIVITY_STATES } from "./activity-contracts.ts";
35
+
36
+ import type {
37
+ ActivityState,
38
+ TerminalActivityState,
39
+ WorkflowActivityState,
40
+ WorkflowActivityUpdate,
41
+ } from "./activity-contracts.ts";
70
42
 
71
43
  export interface EvidenceActivitySummary {
72
44
  kind: "evidence";
@@ -224,7 +196,7 @@ export function sanitizeFailureReason(value: unknown, maxLength = 240): string {
224
196
  function safeDesignFailure(value: unknown): {
225
197
  operation: string;
226
198
  code: string;
227
- diagnostics: Array<Record<string, string>>;
199
+ diagnostics: SafeDesignDiagnostic[];
228
200
  } | null {
229
201
  const failure = asRecord(value);
230
202
  if (
@@ -237,23 +209,9 @@ function safeDesignFailure(value: unknown): {
237
209
  ) {
238
210
  return null;
239
211
  }
240
- const diagnostics = failure.diagnostics.flatMap((candidate) => {
241
- const record = asRecord(candidate);
242
- if (
243
- !record ||
244
- typeof record.code !== "string" ||
245
- !SAFE_DESIGN_DETAIL_VALUE.test(record.code)
246
- ) {
247
- return [];
248
- }
249
- return [
250
- Object.fromEntries(
251
- Object.entries(record).filter(
252
- ([, entry]) =>
253
- typeof entry === "string" && SAFE_DESIGN_DETAIL_VALUE.test(entry),
254
- ),
255
- ) as Record<string, string>,
256
- ];
212
+ const diagnostics = failure.diagnostics.slice(0, 64).flatMap((candidate) => {
213
+ const diagnostic = projectDesignDiagnostic(candidate);
214
+ return diagnostic ? [diagnostic] : [];
257
215
  });
258
216
  return {
259
217
  operation: failure.operation,
@@ -286,7 +244,10 @@ function renderDesignFailure(
286
244
  const lines = failure.diagnostics.map((diagnostic) => {
287
245
  const details = Object.entries(diagnostic)
288
246
  .filter(([key]) => key !== "code")
289
- .map(([key, value]) => `${key}=${value}`)
247
+ .map(
248
+ ([key, value]) =>
249
+ `${key}=${Array.isArray(value) ? JSON.stringify(value) : value}`,
250
+ )
290
251
  .join(" · ");
291
252
  return ` - ${diagnostic.code}${details ? ` · ${details}` : ""}`;
292
253
  });
@@ -315,6 +276,7 @@ function stateGlyph(state: ActivityState, spinnerFrame = 0): string {
315
276
  switch (state) {
316
277
  case "queued":
317
278
  return "…";
279
+ case "preparing":
318
280
  case "connecting":
319
281
  case "waiting-first-response":
320
282
  case "running":
@@ -563,6 +525,8 @@ function workflowStateOf(
563
525
  case "ready":
564
526
  case "queued":
565
527
  return "queued";
528
+ case "preparing":
529
+ return "preparing";
566
530
  case "connecting":
567
531
  return "connecting";
568
532
  case "waiting-first-response":
@@ -681,6 +645,7 @@ function workflowWait(
681
645
  ) {
682
646
  return approval.gate;
683
647
  }
648
+ if (state === "preparing") return "worker-setup";
684
649
  if (state === "connecting") return "connection";
685
650
  if (state === "waiting-first-response") return "first-response";
686
651
  if (state === "retrying") return "bounded-policy";
@@ -867,6 +832,7 @@ function terminalState(
867
832
  } {
868
833
  return ![
869
834
  "queued",
835
+ "preparing",
870
836
  "connecting",
871
837
  "waiting-first-response",
872
838
  "running",
@@ -17,65 +17,16 @@ import {
17
17
  validateDiffResult,
18
18
  validateEvidenceResult,
19
19
  } from "./contracts.ts";
20
+ import {
21
+ completeEvidenceDraft,
22
+ evidenceDraftSchema,
23
+ } from "./evidence-draft.ts";
20
24
  import {
21
25
  type BeginCandidateInput,
22
26
  TASK_LEDGER_LIMITS,
23
27
  type TaskLedger,
24
28
  } from "./task-ledger.ts";
25
29
 
26
- const compactEvidenceSchema = Type.Object({
27
- id: Type.String(),
28
- role: Type.String(),
29
- kind: Type.Literal("evidence"),
30
- conclusions: Type.Array(Type.String()),
31
- citations: Type.Array(
32
- Type.Object({ path: Type.String(), lines: Type.String() }),
33
- ),
34
- constraints: Type.Array(Type.String()),
35
- dependencies: Type.Array(Type.String()),
36
- risks: Type.Array(Type.String()),
37
- blockingQuestions: Type.Array(Type.String()),
38
- hints: Type.Object({
39
- writeSet: Type.Array(Type.String()),
40
- verification: Type.String(),
41
- agentsImpact: Type.String(),
42
- }),
43
- });
44
-
45
- const designEvidenceSchema = Type.Object(
46
- {
47
- id: Type.String(),
48
- role: Type.Literal("design-explorer"),
49
- kind: Type.Literal("evidence"),
50
- packet_id: Type.String(),
51
- module_name: Type.String(),
52
- scope: Type.Array(Type.String()),
53
- files_read: Type.Array(Type.String()),
54
- evidence: Type.Array(
55
- Type.Object(
56
- {
57
- claim: Type.String(),
58
- path: Type.String(),
59
- line_start: Type.Integer({ minimum: 1 }),
60
- line_end: Type.Integer({ minimum: 1 }),
61
- },
62
- { additionalProperties: false },
63
- ),
64
- ),
65
- existing_structures: Type.Array(Type.String()),
66
- existing_conventions: Type.Array(Type.String()),
67
- constraints_discovered: Type.Array(Type.String()),
68
- open_questions: Type.Array(Type.String()),
69
- dependencies: Type.Array(Type.String()),
70
- write_set_hints: Type.Array(Type.String()),
71
- validation_hints: Type.Array(Type.String()),
72
- agents_impact_hints: Type.Array(Type.String()),
73
- risks: Type.Array(Type.String()),
74
- success_criteria_hints: Type.Array(Type.String()),
75
- },
76
- { additionalProperties: false },
77
- );
78
-
79
30
  const diffSchema = Type.Object({
80
31
  id: Type.String(),
81
32
  role: Type.String(),
@@ -881,13 +832,11 @@ export function createSubmitTool(input: {
881
832
  name: "abel_submit_result",
882
833
  label: "Submit Abel Result",
883
834
  description:
884
- "Submit the one final structured Abel evidence or complete unified-diff result.",
835
+ "Submit one accepted final result. Evidence identity and omitted advisory fields are completed by this tool; keep citations, constraints, risks, and open questions explicit. After a rejection, correct once in this same session. Brief accompanying text is harmless.",
885
836
  executionMode: "sequential",
886
837
  parameters:
887
838
  input.output === "evidence"
888
- ? input.role === "design-explorer"
889
- ? designEvidenceSchema
890
- : compactEvidenceSchema
839
+ ? evidenceDraftSchema(input.requestId, input.role)
891
840
  : diffSchema,
892
841
  async execute(_toolCallId, params) {
893
842
  attempts++;
@@ -916,7 +865,11 @@ export function createSubmitTool(input: {
916
865
  phase: true,
917
866
  };
918
867
  failure = undefined;
919
- const value = params as unknown as Record<string, unknown>;
868
+ const value = (
869
+ input.output === "evidence"
870
+ ? completeEvidenceDraft(params, input.requestId, input.role)
871
+ : params
872
+ ) as Record<string, unknown>;
920
873
  if (value === null || typeof value !== "object") {
921
874
  identity.request = false;
922
875
  identity.role = false;
@@ -963,7 +916,15 @@ export function createSubmitTool(input: {
963
916
  stage: "structural-submit",
964
917
  });
965
918
  throw new Error(
966
- validation.reason ?? "submitted result identity does not match",
919
+ JSON.stringify({
920
+ code: "code" in failure ? failure.code : failure.kind,
921
+ reason:
922
+ validation.reason ?? "submitted result identity does not match",
923
+ identityMismatch: Object.entries(identity)
924
+ .filter(([, matches]) => !matches)
925
+ .map(([field]) => field),
926
+ remainingCorrections: Math.max(0, 2 - attempts),
927
+ }),
967
928
  );
968
929
  }
969
930
  submitted = structuredClone(value) as unknown as
@@ -0,0 +1,32 @@
1
+ /** Request budgets describe observable model progress, never TCP/TLS events. */
2
+ export const REQUEST_BOUNDS = Object.freeze({
3
+ firstProgressMs: 90_000,
4
+ streamIdleMs: 180_000,
5
+ cancellationDrainMs: 250,
6
+ });
7
+ export type TransportTimeoutCode =
8
+ | "first-progress-timeout"
9
+ | "stream-idle-timeout"
10
+ | "attempt-timeout";
11
+ export class TransportTimeout extends Error {
12
+ readonly scope: "request" | "attempt";
13
+ constructor(readonly code: TransportTimeoutCode) {
14
+ super(code);
15
+ this.name = "TransportTimeout";
16
+ this.scope = code === "attempt-timeout" ? "attempt" : "request";
17
+ }
18
+ }
19
+ export function isTransportTimeoutCode(
20
+ code: string,
21
+ ): code is TransportTimeoutCode {
22
+ return [
23
+ "first-progress-timeout",
24
+ "stream-idle-timeout",
25
+ "attempt-timeout",
26
+ ].includes(code);
27
+ }
28
+ export function transportFailureError(code: string): Error {
29
+ return isTransportTimeoutCode(code)
30
+ ? new TransportTimeout(code)
31
+ : new Error("transport-failure");
32
+ }
@@ -5,11 +5,9 @@ import {
5
5
  type WorkerRole,
6
6
  type WorkerRoutePolicy,
7
7
  } from "./route-policy.ts";
8
+ import { TransportTimeout } from "./transport-budget.ts";
8
9
 
9
10
  export const ROUTE_ATTEMPT_BOUNDS = Object.freeze({
10
- connectMs: 10_000,
11
- firstResponseMs: 90_000,
12
- idleMs: 3 * 60_000,
13
11
  totalMs: 20 * 60_000,
14
12
  cooldownMs: 30_000,
15
13
  attemptsPerRoute: 2,
@@ -71,6 +69,7 @@ export interface RunWorkerAttemptInput<T> {
71
69
  export interface RouteAttemptContext {
72
70
  route: WorkerRoutePolicy;
73
71
  signal: AbortSignal;
72
+ onRequestStart(): void;
74
73
  onHeaders(): void;
75
74
  onProgress(): void;
76
75
  }
@@ -81,7 +80,7 @@ export interface BrokerAttemptEvidence {
81
80
  }
82
81
 
83
82
  export interface BrokerActivityUpdate {
84
- state: "connecting" | "waiting-first-response" | "running" | "retrying";
83
+ state: "preparing" | "waiting-first-response" | "running" | "retrying";
85
84
  attempt: number;
86
85
  maxAttempts: number;
87
86
  code?: string;
@@ -100,12 +99,7 @@ function emitBrokerActivity(
100
99
  }
101
100
 
102
101
  class RouteAttemptStop extends Error {
103
- readonly code:
104
- | "connect-timeout"
105
- | "first-response-timeout"
106
- | "idle-timeout"
107
- | "phase-timeout"
108
- | "cancelled";
102
+ readonly code: "cancelled";
109
103
 
110
104
  constructor(code: RouteAttemptStop["code"]) {
111
105
  super(code);
@@ -182,7 +176,8 @@ function capable(
182
176
  }
183
177
 
184
178
  function safeFailureCode(error: unknown): string {
185
- if (error instanceof RouteAttemptStop) return error.code;
179
+ if (error instanceof RouteAttemptStop || error instanceof TransportTimeout)
180
+ return error.code;
186
181
  return "transport-failure";
187
182
  }
188
183
 
@@ -331,59 +326,39 @@ export class WorkerBroker {
331
326
  onActivity?: (state: "waiting-first-response" | "running") => void,
332
327
  ): Promise<T> {
333
328
  const controller = new AbortController();
334
- const timers = new Set<ReturnType<typeof setTimeout>>();
335
- let headersSeen = false;
336
- let connectTimer: ReturnType<typeof setTimeout> | undefined;
337
- let firstResponseTimer: ReturnType<typeof setTimeout> | undefined;
338
- let idleTimer: ReturnType<typeof setTimeout> | undefined;
339
-
340
- const stop = (code: RouteAttemptStop["code"]) => {
341
- if (!controller.signal.aborted)
342
- controller.abort(new RouteAttemptStop(code));
343
- };
344
- const schedule = (code: RouteAttemptStop["code"], milliseconds: number) => {
345
- const timer = setTimeout(() => stop(code), milliseconds);
346
- timers.add(timer);
347
- return timer;
348
- };
349
- const clear = (timer: ReturnType<typeof setTimeout> | undefined) => {
350
- if (timer !== undefined) {
351
- clearTimeout(timer);
352
- timers.delete(timer);
353
- }
354
- };
355
- const onParentAbort = () => stop("cancelled");
356
- if (parentSignal?.aborted) stop("cancelled");
329
+ let accepting = true;
330
+ let running = false;
331
+ const onParentAbort = () =>
332
+ controller.abort(new RouteAttemptStop("cancelled"));
333
+ if (parentSignal?.aborted) onParentAbort();
357
334
  else parentSignal?.addEventListener("abort", onParentAbort, { once: true });
358
-
359
- connectTimer = schedule("connect-timeout", ROUTE_ATTEMPT_BOUNDS.connectMs);
360
- schedule("phase-timeout", ROUTE_ATTEMPT_BOUNDS.totalMs);
335
+ const timer = setTimeout(
336
+ () => controller.abort(new TransportTimeout("attempt-timeout")),
337
+ ROUTE_ATTEMPT_BOUNDS.totalMs,
338
+ );
361
339
  let removeAbortRace: () => void = () => {};
362
340
  try {
363
- const execution = Promise.resolve().then(() =>
364
- execute({
341
+ const execution = Promise.resolve().then(() => {
342
+ controller.signal.throwIfAborted();
343
+ return execute({
365
344
  route: structuredClone(route),
366
345
  signal: controller.signal,
346
+ onRequestStart: () => {
347
+ if (!accepting || controller.signal.aborted) return;
348
+ running = false;
349
+ onActivity?.("waiting-first-response");
350
+ },
367
351
  onHeaders: () => {
368
- if (controller.signal.aborted || headersSeen) return;
369
- headersSeen = true;
370
- clear(connectTimer);
352
+ if (!accepting || controller.signal.aborted || running) return;
371
353
  onActivity?.("waiting-first-response");
372
- firstResponseTimer = schedule(
373
- "first-response-timeout",
374
- ROUTE_ATTEMPT_BOUNDS.firstResponseMs,
375
- );
376
354
  },
377
355
  onProgress: () => {
378
- if (controller.signal.aborted) return;
379
- clear(connectTimer);
380
- clear(firstResponseTimer);
381
- clear(idleTimer);
382
- idleTimer = schedule("idle-timeout", ROUTE_ATTEMPT_BOUNDS.idleMs);
356
+ if (!accepting || controller.signal.aborted) return;
357
+ running = true;
383
358
  onActivity?.("running");
384
359
  },
385
- }),
386
- );
360
+ });
361
+ });
387
362
  // A timed-out Provider may ignore its AbortSignal and settle later. The
388
363
  // broker owns the bounded result, so absorb that late settlement.
389
364
  void execution.catch(() => undefined);
@@ -403,7 +378,8 @@ export class WorkerBroker {
403
378
  return await Promise.race([execution, aborted]);
404
379
  } finally {
405
380
  removeAbortRace();
406
- for (const timer of timers) clearTimeout(timer);
381
+ accepting = false;
382
+ clearTimeout(timer);
407
383
  parentSignal?.removeEventListener("abort", onParentAbort);
408
384
  }
409
385
  }
@@ -483,10 +459,10 @@ export class WorkerBroker {
483
459
  ).length;
484
460
  const attempt = evidence.length + 1;
485
461
  emitBrokerActivity(input.onActivity, {
486
- state: "connecting",
462
+ state: "preparing",
487
463
  attempt,
488
464
  maxAttempts,
489
- wait: "connection",
465
+ wait: "worker-setup",
490
466
  });
491
467
  try {
492
468
  const value = await this.#attempt(
@@ -576,6 +552,7 @@ export class WorkerBroker {
576
552
  code:
577
553
  | "approval-boundary"
578
554
  | "route-not-declared"
555
+ | "endpoint-unavailable"
579
556
  | "route-capability-insufficient";
580
557
  } {
581
558
  const allowed = new Set(["runId", "role", "routeId", "requirements"]);
@@ -597,12 +574,15 @@ export class WorkerBroker {
597
574
  routeId: input.routeId,
598
575
  });
599
576
  if (!selection.ok) {
577
+ const route = this.#policy.routes[input.routeId];
600
578
  return {
601
579
  ok: false,
602
580
  code:
603
581
  selection.code === "route-not-declared"
604
582
  ? "route-not-declared"
605
- : "route-capability-insufficient",
583
+ : route && capable(route, input.role, input.requirements ?? {})
584
+ ? "endpoint-unavailable"
585
+ : "route-capability-insufficient",
606
586
  };
607
587
  }
608
588
  return {
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import type { WorkflowActivityUpdate } from "./activity-contracts.ts";
4
5
  import { compareCanonicalStrings } from "./canonical.ts";
5
6
  import {
6
7
  type ApprovalBoundaryCode,
@@ -18,7 +19,6 @@ import type { RoutePolicy } from "./route-policy.ts";
18
19
  import { canonicalJson } from "./run-state.ts";
19
20
  import type { OperationLease } from "./run-store.ts";
20
21
  import type { ResolvedStateRoot } from "./state-root.ts";
21
- import type { WorkflowActivityUpdate } from "./subagent-activity.ts";
22
22
  import {
23
23
  type CandidateContextBoundary,
24
24
  type CandidateContextRef,
@@ -185,59 +185,20 @@ export interface RedArtifactCorrection {
185
185
  contextRequest?: WorkflowContextRequest;
186
186
  }
187
187
 
188
- export type RecoveryActionRequest = {
189
- kind: "affected-repair" | "cumulative-repair" | "red-correction";
190
- attempt: number;
191
- };
192
- export type RecoveryActionDecision =
193
- | { allowed: true }
194
- | {
195
- allowed: false;
196
- code: "repair-attempts-exhausted" | "artifact-attempts-exhausted";
197
- };
188
+ export type {
189
+ RecoveryActionDecision,
190
+ RecoveryActionRequest,
191
+ WorkflowRecoveryFact,
192
+ WorkflowRecoveryFeedback,
193
+ } from "./workflow-recovery-policy.ts";
194
+ export { decideRecoveryAction } from "./workflow-recovery-policy.ts";
198
195
 
199
- /** One policy for every nested recovery action; execution services supply facts only. */
200
- export function decideRecoveryAction(
201
- plan: ImplementPlan,
202
- request: RecoveryActionRequest,
203
- authority: { additionalAttempt?: boolean; verificationOnly?: boolean } = {},
204
- ): RecoveryActionDecision {
205
- const correction = request.kind === "red-correction";
206
- const limit =
207
- authority.additionalAttempt || authority.verificationOnly
208
- ? 1
209
- : correction
210
- ? plan.verification.artifactCorrection.maxAttempts
211
- : plan.verification.repair.maxAttempts;
212
- return Number.isSafeInteger(request.attempt) &&
213
- request.attempt > 0 &&
214
- request.attempt <= limit
215
- ? { allowed: true }
216
- : {
217
- allowed: false,
218
- code: correction
219
- ? "artifact-attempts-exhausted"
220
- : "repair-attempts-exhausted",
221
- };
222
- }
223
-
224
- export interface WorkflowRecoveryFeedback {
225
- code: string;
226
- attempt: number;
227
- maxAttempts: number;
228
- strategy:
229
- | "revise-candidate"
230
- | "refresh-candidate"
231
- | "repair-verification"
232
- | "compact-patch";
233
- failureIdentities?: string[];
234
- }
235
-
236
- export interface WorkflowRecoveryFact {
237
- key: string;
238
- failures: number;
239
- feedback: WorkflowRecoveryFeedback;
240
- }
196
+ import type {
197
+ RecoveryActionDecision,
198
+ RecoveryActionRequest,
199
+ WorkflowRecoveryFact,
200
+ WorkflowRecoveryFeedback,
201
+ } from "./workflow-recovery-policy.ts";
241
202
 
242
203
  export const APPROVAL_AUTHORITY_CATEGORIES = [
243
204
  "observable-behavior",
@@ -0,0 +1,95 @@
1
+ import type { ImplementPlan } from "./implement-plan.ts";
2
+
3
+ export type RecoveryActionRequest = {
4
+ kind: "affected-repair" | "cumulative-repair" | "red-correction";
5
+ attempt: number;
6
+ };
7
+ export type RecoveryActionDecision =
8
+ | { allowed: true }
9
+ | {
10
+ allowed: false;
11
+ code: "repair-attempts-exhausted" | "artifact-attempts-exhausted";
12
+ };
13
+
14
+ /** One policy for every nested recovery action; execution services supply facts only. */
15
+ export function decideRecoveryAction(
16
+ plan: ImplementPlan,
17
+ request: RecoveryActionRequest,
18
+ authority: { additionalAttempt?: boolean; verificationOnly?: boolean } = {},
19
+ ): RecoveryActionDecision {
20
+ const correction = request.kind === "red-correction";
21
+ const limit =
22
+ authority.additionalAttempt || authority.verificationOnly
23
+ ? 1
24
+ : correction
25
+ ? plan.verification.artifactCorrection.maxAttempts
26
+ : plan.verification.repair.maxAttempts;
27
+ return Number.isSafeInteger(request.attempt) &&
28
+ request.attempt > 0 &&
29
+ request.attempt <= limit
30
+ ? { allowed: true }
31
+ : {
32
+ allowed: false,
33
+ code: correction
34
+ ? "artifact-attempts-exhausted"
35
+ : "repair-attempts-exhausted",
36
+ };
37
+ }
38
+
39
+ export interface WorkflowRecoveryFeedback {
40
+ code: string;
41
+ attempt: number;
42
+ maxAttempts: number;
43
+ strategy:
44
+ | "revise-candidate"
45
+ | "refresh-candidate"
46
+ | "repair-verification"
47
+ | "compact-patch";
48
+ failureIdentities?: string[];
49
+ }
50
+
51
+ export interface WorkflowRecoveryFact {
52
+ key: string;
53
+ failures: number;
54
+ feedback: WorkflowRecoveryFeedback;
55
+ }
56
+
57
+ export function recoveryExhausted(
58
+ incident: WorkflowRecoveryFact | undefined,
59
+ maximum: number,
60
+ granted: boolean,
61
+ ): boolean {
62
+ return !granted && incident !== undefined && incident.failures >= maximum;
63
+ }
64
+
65
+ /** A grant is a recommendation until the state machine validates and records it in its lease transaction. */
66
+ export function assessRecoveryGrant(input: {
67
+ current: boolean;
68
+ incident?: WorkflowRecoveryFact;
69
+ failureSequence: number;
70
+ requestedSequence: number;
71
+ reason: string;
72
+ previous: { route?: string | null; context?: string | null };
73
+ observed: { route?: string | null; context?: string | null };
74
+ budget?: { used: number; max_work: number };
75
+ }): string | undefined {
76
+ if (
77
+ !input.current ||
78
+ !input.incident ||
79
+ input.incident.failures < input.incident.feedback.maxAttempts ||
80
+ input.failureSequence !== input.requestedSequence
81
+ )
82
+ return "recovery-request-stale";
83
+ if (
84
+ (input.reason === "route-changed" &&
85
+ (!input.previous.route ||
86
+ input.previous.route === input.observed.route)) ||
87
+ (input.reason === "context-extended" &&
88
+ (!input.previous.context ||
89
+ input.previous.context === input.observed.context))
90
+ )
91
+ return "recovery-evidence-unavailable";
92
+ if (!input.budget || input.budget.used >= input.budget.max_work)
93
+ return "change-work-budget-exhausted";
94
+ return undefined;
95
+ }