harnery 0.21.0 → 0.22.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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/core/work/index.d.ts +1 -1
  3. package/dist/core/work/index.d.ts.map +1 -1
  4. package/dist/core/work/read.d.ts +1 -1
  5. package/dist/core/work/read.d.ts.map +1 -1
  6. package/dist/core/work/runner.d.ts +1 -1
  7. package/dist/core/work/runner.d.ts.map +1 -1
  8. package/dist/core/work/runner.js +54 -1
  9. package/dist/core/work/state.d.ts +3 -0
  10. package/dist/core/work/state.d.ts.map +1 -1
  11. package/dist/core/work/state.js +19 -5
  12. package/dist/core/workflow/attempt-context.d.ts +5 -0
  13. package/dist/core/workflow/attempt-context.d.ts.map +1 -0
  14. package/dist/core/workflow/attempt-context.js +189 -0
  15. package/dist/core/workflow/engine.d.ts.map +1 -1
  16. package/dist/core/workflow/engine.js +20 -2
  17. package/dist/core/workflow/index.d.ts +3 -2
  18. package/dist/core/workflow/index.d.ts.map +1 -1
  19. package/dist/core/workflow/index.js +2 -1
  20. package/dist/core/workflow/proof.d.ts +2 -1
  21. package/dist/core/workflow/proof.d.ts.map +1 -1
  22. package/dist/core/workflow/proof.js +7 -1
  23. package/dist/core/workflow/run-state.d.ts +2 -1
  24. package/dist/core/workflow/run-state.d.ts.map +1 -1
  25. package/dist/core/workflow/run-state.js +5 -0
  26. package/dist/core/workflow/types.d.ts +27 -0
  27. package/dist/core/workflow/types.d.ts.map +1 -1
  28. package/dist/core/workflow/types.js +1 -0
  29. package/dist/core/workflow/validate.d.ts +10 -1
  30. package/dist/core/workflow/validate.d.ts.map +1 -1
  31. package/dist/core/workflow/validate.js +75 -1
  32. package/package.json +1 -1
  33. package/src/core/work/index.ts +1 -0
  34. package/src/core/work/read.ts +1 -0
  35. package/src/core/work/runner.ts +69 -3
  36. package/src/core/work/state.ts +36 -5
  37. package/src/core/workflow/attempt-context.ts +215 -0
  38. package/src/core/workflow/engine.ts +29 -1
  39. package/src/core/workflow/index.ts +10 -0
  40. package/src/core/workflow/proof.ts +9 -1
  41. package/src/core/workflow/run-state.ts +11 -1
  42. package/src/core/workflow/types.ts +35 -0
  43. package/src/core/workflow/validate.ts +75 -1
@@ -0,0 +1,215 @@
1
+ import type {
2
+ AcceptanceSummary,
3
+ WorkflowAttemptContext,
4
+ WorkflowAttemptFailureCause,
5
+ WorkflowAttemptPriorContext,
6
+ WorkflowAttemptUnresolvedCriterion,
7
+ } from "./types.ts";
8
+ import { WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION } from "./types.ts";
9
+
10
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
11
+ const ACCEPTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
+ const MAX_ATTEMPTS = 100;
13
+ const MAX_ERROR = 2_000;
14
+ const MAX_CRITERIA = 50;
15
+ const MAX_STATEMENT = 500;
16
+ const CONTEXT_FIELDS = new Set(["schema_version", "number", "trigger", "prior"]);
17
+ const PRIOR_FIELDS = new Set(["run_id", "causes", "error", "acceptance", "unresolved"]);
18
+ const SUMMARY_FIELDS = new Set(["satisfied", "unsatisfied", "unknown", "total"]);
19
+ const CRITERION_FIELDS = new Set(["id", "statement", "status"]);
20
+ const CAUSE_ORDER: readonly WorkflowAttemptFailureCause[] = [
21
+ "workflow_error",
22
+ "acceptance_unsatisfied",
23
+ "acceptance_unknown",
24
+ "lost",
25
+ ];
26
+
27
+ export function normalizeWorkflowAttemptContext(value: unknown): WorkflowAttemptContext {
28
+ const input = objectValue(value, "workflow attempt context");
29
+ rejectExtraFields(input, CONTEXT_FIELDS, "workflow attempt context");
30
+ if (input.schema_version !== WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION) {
31
+ throw new Error("workflow attempt context has an unsupported schema");
32
+ }
33
+ const number = safeInteger(input.number, "workflow attempt context number", 1, MAX_ATTEMPTS);
34
+ if (input.trigger !== "initial" && input.trigger !== "retry") {
35
+ throw new Error('workflow attempt context trigger must be "initial" or "retry"');
36
+ }
37
+ if (input.trigger === "initial") {
38
+ if (input.prior !== undefined) {
39
+ throw new Error("initial workflow attempt context cannot contain prior evidence");
40
+ }
41
+ return {
42
+ schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
43
+ number,
44
+ trigger: "initial",
45
+ };
46
+ }
47
+ if (number < 2) throw new Error("retry workflow attempt context number must be at least 2");
48
+ if (input.prior === undefined) {
49
+ throw new Error("retry workflow attempt context requires prior evidence");
50
+ }
51
+ return {
52
+ schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
53
+ number,
54
+ trigger: "retry",
55
+ prior: normalizePrior(input.prior),
56
+ };
57
+ }
58
+
59
+ export function freezeWorkflowAttemptContext(value: unknown): Readonly<WorkflowAttemptContext> {
60
+ const normalized = normalizeWorkflowAttemptContext(value);
61
+ if (normalized.prior) {
62
+ for (const criterion of normalized.prior.unresolved) Object.freeze(criterion);
63
+ Object.freeze(normalized.prior.causes);
64
+ Object.freeze(normalized.prior.acceptance);
65
+ Object.freeze(normalized.prior.unresolved);
66
+ Object.freeze(normalized.prior);
67
+ }
68
+ return Object.freeze(normalized);
69
+ }
70
+
71
+ export function isCanonicalWorkflowAttemptContext(value: unknown): value is WorkflowAttemptContext {
72
+ try {
73
+ const normalized = normalizeWorkflowAttemptContext(value);
74
+ return JSON.stringify(normalized) === JSON.stringify(value);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function normalizePrior(value: unknown): WorkflowAttemptPriorContext {
81
+ const input = objectValue(value, "workflow attempt prior context");
82
+ rejectExtraFields(input, PRIOR_FIELDS, "workflow attempt prior context");
83
+ const runId = requiredString(input.run_id, "workflow attempt prior run id", 200);
84
+ if (!RUN_ID.test(runId)) {
85
+ throw new Error(`invalid workflow attempt prior run id ${JSON.stringify(runId)}`);
86
+ }
87
+ if (!Array.isArray(input.causes) || input.causes.length === 0) {
88
+ throw new Error("workflow attempt prior causes must be a non-empty array");
89
+ }
90
+ const seen = new Set<WorkflowAttemptFailureCause>();
91
+ for (const cause of input.causes) {
92
+ if (!CAUSE_ORDER.includes(cause as WorkflowAttemptFailureCause)) {
93
+ throw new Error(`unsupported workflow attempt failure cause ${JSON.stringify(cause)}`);
94
+ }
95
+ if (seen.has(cause as WorkflowAttemptFailureCause)) {
96
+ throw new Error(`duplicate workflow attempt failure cause ${JSON.stringify(cause)}`);
97
+ }
98
+ seen.add(cause as WorkflowAttemptFailureCause);
99
+ }
100
+ const causes = CAUSE_ORDER.filter((cause) => seen.has(cause));
101
+ if (causes.includes("lost")) {
102
+ if (causes.length !== 1)
103
+ throw new Error("lost must be the only workflow attempt failure cause");
104
+ if (input.error !== undefined || input.acceptance !== undefined) {
105
+ throw new Error("lost workflow attempt evidence cannot contain proof-derived fields");
106
+ }
107
+ const unresolved = normalizeUnresolved(input.unresolved);
108
+ if (unresolved.length > 0) {
109
+ throw new Error("lost workflow attempt evidence cannot contain unresolved criteria");
110
+ }
111
+ return { run_id: runId, causes, unresolved };
112
+ }
113
+
114
+ const acceptance = normalizeAcceptanceSummary(input.acceptance);
115
+ const unresolved = normalizeUnresolved(input.unresolved);
116
+ const unsatisfied = unresolved.filter((criterion) => criterion.status === "unsatisfied").length;
117
+ const unknown = unresolved.filter((criterion) => criterion.status === "unknown").length;
118
+ if (
119
+ unsatisfied !== acceptance.unsatisfied ||
120
+ unknown !== acceptance.unknown ||
121
+ causes.includes("acceptance_unsatisfied") !== acceptance.unsatisfied > 0 ||
122
+ causes.includes("acceptance_unknown") !== acceptance.unknown > 0
123
+ ) {
124
+ throw new Error("workflow attempt failure evidence does not match acceptance");
125
+ }
126
+ if (!causes.includes("workflow_error") && acceptance.unsatisfied + acceptance.unknown === 0) {
127
+ throw new Error("workflow attempt prior context does not describe a failure");
128
+ }
129
+ const error =
130
+ input.error === undefined
131
+ ? undefined
132
+ : requiredString(input.error, "workflow attempt prior error", MAX_ERROR);
133
+ return {
134
+ run_id: runId,
135
+ causes,
136
+ ...(error === undefined ? {} : { error }),
137
+ acceptance,
138
+ unresolved,
139
+ };
140
+ }
141
+
142
+ function normalizeAcceptanceSummary(value: unknown): AcceptanceSummary {
143
+ const input = objectValue(value, "workflow attempt acceptance summary");
144
+ rejectExtraFields(input, SUMMARY_FIELDS, "workflow attempt acceptance summary");
145
+ const satisfied = safeInteger(input.satisfied, "acceptance satisfied", 0, MAX_CRITERIA);
146
+ const unsatisfied = safeInteger(input.unsatisfied, "acceptance unsatisfied", 0, MAX_CRITERIA);
147
+ const unknown = safeInteger(input.unknown, "acceptance unknown", 0, MAX_CRITERIA);
148
+ const total = safeInteger(input.total, "acceptance total", 0, MAX_CRITERIA);
149
+ if (satisfied + unsatisfied + unknown !== total) {
150
+ throw new Error("workflow attempt acceptance summary total does not match its counts");
151
+ }
152
+ return { satisfied, unsatisfied, unknown, total };
153
+ }
154
+
155
+ function normalizeUnresolved(value: unknown): WorkflowAttemptUnresolvedCriterion[] {
156
+ if (!Array.isArray(value) || value.length > MAX_CRITERIA) {
157
+ throw new Error(
158
+ `workflow attempt unresolved criteria must contain at most ${MAX_CRITERIA} entries`,
159
+ );
160
+ }
161
+ const ids = new Set<string>();
162
+ return value.map((candidate, index) => {
163
+ const input = objectValue(candidate, `workflow attempt unresolved[${index}]`);
164
+ rejectExtraFields(input, CRITERION_FIELDS, `workflow attempt unresolved[${index}]`);
165
+ const id = requiredString(input.id, `workflow attempt unresolved[${index}].id`, 64);
166
+ if (!ACCEPTANCE_ID.test(id)) {
167
+ throw new Error(`invalid workflow attempt acceptance id ${JSON.stringify(id)}`);
168
+ }
169
+ if (ids.has(id))
170
+ throw new Error(`duplicate workflow attempt acceptance id ${JSON.stringify(id)}`);
171
+ ids.add(id);
172
+ if (input.status !== "unsatisfied" && input.status !== "unknown") {
173
+ throw new Error(`workflow attempt unresolved[${index}].status is invalid`);
174
+ }
175
+ return {
176
+ id,
177
+ statement: requiredString(
178
+ input.statement,
179
+ `workflow attempt unresolved[${index}].statement`,
180
+ MAX_STATEMENT,
181
+ ),
182
+ status: input.status,
183
+ };
184
+ });
185
+ }
186
+
187
+ function objectValue(value: unknown, field: string): Record<string, unknown> {
188
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
189
+ throw new Error(`${field} must be an object`);
190
+ }
191
+ return value as Record<string, unknown>;
192
+ }
193
+
194
+ function rejectExtraFields(
195
+ value: Record<string, unknown>,
196
+ allowed: ReadonlySet<string>,
197
+ field: string,
198
+ ): void {
199
+ const extra = Object.keys(value).find((key) => !allowed.has(key));
200
+ if (extra) throw new Error(`${field} has unknown field ${JSON.stringify(extra)}`);
201
+ }
202
+
203
+ function requiredString(value: unknown, field: string, max: number): string {
204
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} is required`);
205
+ const normalized = value.trim();
206
+ if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
207
+ return normalized;
208
+ }
209
+
210
+ function safeInteger(value: unknown, field: string, min: number, max: number): number {
211
+ if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max) {
212
+ throw new Error(`${field} must be an integer from ${min} to ${max}`);
213
+ }
214
+ return value as number;
215
+ }
@@ -36,6 +36,7 @@ import {
36
36
  summarizePolicyRequest,
37
37
  } from "../policy/index.ts";
38
38
  import { assertWorkflowRunId, createWorkflowApproval } from "./approvals.ts";
39
+ import { freezeWorkflowAttemptContext } from "./attempt-context.ts";
39
40
  import { type BillingProbe, probeBilling } from "./billing.ts";
40
41
  import {
41
42
  buildWorkflowProof,
@@ -60,6 +61,7 @@ import type {
60
61
  SpawnResult,
61
62
  StageSchema,
62
63
  WorkflowAgentProof,
64
+ WorkflowAttemptContext,
63
65
  WorkflowContext,
64
66
  WorkflowEvidenceRecord,
65
67
  WorkflowModule,
@@ -115,8 +117,15 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
115
117
  if (opts.resumeRunId && opts.workContext !== undefined) {
116
118
  throw new Error("parked workflow resume uses the frozen manifest work context");
117
119
  }
120
+ if (opts.resumeRunId && opts.attemptContext !== undefined) {
121
+ throw new Error("parked workflow resume uses the frozen manifest attempt context");
122
+ }
118
123
  const requestedWorkContext =
119
124
  opts.workContext === undefined ? undefined : freezeWorkflowWorkContext(opts.workContext);
125
+ const requestedAttemptContext =
126
+ opts.attemptContext === undefined
127
+ ? undefined
128
+ : freezeWorkflowAttemptContext(opts.attemptContext);
120
129
  if (requestedWorkContext && !opts.workItemId) {
121
130
  throw new Error("workflow work context requires a work item id");
122
131
  }
@@ -125,6 +134,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
125
134
  `workflow work context ${requestedWorkContext.id} does not match work item ${opts.workItemId}`,
126
135
  );
127
136
  }
137
+ if (requestedAttemptContext && !requestedWorkContext) {
138
+ throw new Error("workflow attempt context requires work context");
139
+ }
128
140
  if (opts.resumeRunId && opts.resumeFrom) {
129
141
  throw new Error("resumeRunId and resumeFrom are mutually exclusive");
130
142
  }
@@ -141,6 +153,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
141
153
  const workContext = resumeState?.manifest.work_context
142
154
  ? freezeWorkflowWorkContext(resumeState.manifest.work_context)
143
155
  : requestedWorkContext;
156
+ const attemptContext = resumeState?.manifest.attempt_context
157
+ ? freezeWorkflowAttemptContext(resumeState.manifest.attempt_context)
158
+ : requestedAttemptContext;
144
159
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
145
160
  if (resumeState) {
146
161
  if (resolve(resumeState.manifest.script.path) !== resolve(absScript)) {
@@ -157,7 +172,14 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
157
172
  // the run between the optimistic read and lease acquisition.
158
173
  assertWorkflowRunResumable(opts.coordRoot, resumeState.manifest.run_id);
159
174
  }
160
- return await executeWorkflow(scriptPath, absScript, opts, resumeState, workContext);
175
+ return await executeWorkflow(
176
+ scriptPath,
177
+ absScript,
178
+ opts,
179
+ resumeState,
180
+ workContext,
181
+ attemptContext,
182
+ );
161
183
  } finally {
162
184
  releaseResumeLease?.();
163
185
  }
@@ -169,6 +191,7 @@ async function executeWorkflow(
169
191
  opts: EngineOpts,
170
192
  resumeState: ReturnType<typeof assertWorkflowRunResumable> | undefined,
171
193
  workContext: Readonly<WorkflowWorkContext> | undefined,
194
+ attemptContext: Readonly<WorkflowAttemptContext> | undefined,
172
195
  ): Promise<RunReport> {
173
196
  const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
174
197
  if (typeof mod.default !== "function") {
@@ -218,6 +241,7 @@ async function executeWorkflow(
218
241
  run_id: runId,
219
242
  work_item_id: opts.workItemId,
220
243
  work_context: workContext,
244
+ attempt_context: attemptContext,
221
245
  name,
222
246
  started_at: startedAt,
223
247
  script: { path: absScript, sha256: workflowScriptDigest(absScript) },
@@ -784,6 +808,7 @@ async function executeWorkflow(
784
808
 
785
809
  const ctx: WorkflowContext = {
786
810
  work: workContext,
811
+ attempt: attemptContext,
787
812
  agent,
788
813
  parallel,
789
814
  stage,
@@ -803,6 +828,7 @@ async function executeWorkflow(
803
828
  name,
804
829
  work_item_id: opts.workItemId ?? null,
805
830
  work_context: workContext ?? null,
831
+ attempt_context: attemptContext ?? null,
806
832
  script: absScript,
807
833
  objective: meta.objective ?? null,
808
834
  acceptance: meta.acceptance,
@@ -830,6 +856,7 @@ async function executeWorkflow(
830
856
  runId,
831
857
  workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
832
858
  workContext,
859
+ attemptContext,
833
860
  meta,
834
861
  status: "succeeded",
835
862
  startedAt,
@@ -897,6 +924,7 @@ async function executeWorkflow(
897
924
  runId,
898
925
  workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
899
926
  workContext,
927
+ attemptContext,
900
928
  meta,
901
929
  status: "failed",
902
930
  startedAt,
@@ -16,6 +16,11 @@ export {
16
16
  WORKFLOW_APPROVAL_SCHEMA_VERSION,
17
17
  workflowApprovalId,
18
18
  } from "./approvals.ts";
19
+ export {
20
+ freezeWorkflowAttemptContext,
21
+ isCanonicalWorkflowAttemptContext,
22
+ normalizeWorkflowAttemptContext,
23
+ } from "./attempt-context.ts";
19
24
  export { runWorkflow, WorkflowParkedError, WorkflowRunError } from "./engine.ts";
20
25
  export {
21
26
  buildWorkflowProof,
@@ -59,6 +64,10 @@ export type {
59
64
  StageSchema,
60
65
  WorkflowAgentProof,
61
66
  WorkflowApprovalMode,
67
+ WorkflowAttemptContext,
68
+ WorkflowAttemptFailureCause,
69
+ WorkflowAttemptPriorContext,
70
+ WorkflowAttemptUnresolvedCriterion,
62
71
  WorkflowContext,
63
72
  WorkflowEvidenceInput,
64
73
  WorkflowEvidenceRecord,
@@ -74,6 +83,7 @@ export type {
74
83
  WorkflowWorkContext,
75
84
  } from "./types.ts";
76
85
  export {
86
+ WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
77
87
  WORKFLOW_PROOF_SCHEMA_VERSION,
78
88
  WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION,
79
89
  } from "./types.ts";
@@ -18,6 +18,7 @@ import type {
18
18
  PolicyNetworkAccess,
19
19
  } from "../policy/index.ts";
20
20
  import { policyDigest } from "../policy/index.ts";
21
+ import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
21
22
  import type {
22
23
  AcceptanceCriterion,
23
24
  AcceptanceResult,
@@ -26,6 +27,7 @@ import type {
26
27
  HarnessEvidenceCoverage,
27
28
  ResultDigest,
28
29
  WorkflowAgentProof,
30
+ WorkflowAttemptContext,
29
31
  WorkflowEvidenceInput,
30
32
  WorkflowEvidenceRecord,
31
33
  WorkflowMeta,
@@ -62,6 +64,7 @@ export interface BuildWorkflowProofInput {
62
64
  runId: string;
63
65
  workItemId?: string;
64
66
  workContext?: Readonly<WorkflowWorkContext>;
67
+ attemptContext?: Readonly<WorkflowAttemptContext>;
65
68
  meta: NormalizedWorkflowMeta;
66
69
  status: "succeeded" | "failed";
67
70
  startedAt: string;
@@ -225,6 +228,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
225
228
  ended_at: input.endedAt,
226
229
  duration_ms: input.durationMs,
227
230
  work_context: input.workContext,
231
+ attempt_context: input.attemptContext,
228
232
  objective: input.meta.objective,
229
233
  error: clippedOptional(input.error, MAX_SUMMARY_CHARS),
230
234
  result: input.result === undefined ? undefined : digestResult(input.result),
@@ -313,7 +317,11 @@ export function readWorkflowProof(coordRoot: string, runId: string): WorkflowPro
313
317
  (proof.run.work_context !== undefined &&
314
318
  (!proof.run.work_item_id ||
315
319
  proof.run.work_context.id !== proof.run.work_item_id ||
316
- !isCanonicalWorkflowWorkContext(proof.run.work_context)))
320
+ !isCanonicalWorkflowWorkContext(proof.run.work_context))) ||
321
+ (proof.run.attempt_context !== undefined &&
322
+ (!proof.run.work_item_id ||
323
+ !proof.run.work_context ||
324
+ !isCanonicalWorkflowAttemptContext(proof.run.attempt_context)))
317
325
  ) {
318
326
  throw new Error(`workflow proof at ${path} has an unsupported or mismatched schema`);
319
327
  }
@@ -22,8 +22,13 @@ import {
22
22
  policyDigest,
23
23
  } from "../policy/index.ts";
24
24
  import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
25
+ import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
25
26
  import { normalizeWorkflowSpecialists } from "./specialists.ts";
26
- import type { WorkflowSpecialistProfile, WorkflowWorkContext } from "./types.ts";
27
+ import type {
28
+ WorkflowAttemptContext,
29
+ WorkflowSpecialistProfile,
30
+ WorkflowWorkContext,
31
+ } from "./types.ts";
27
32
  import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
28
33
 
29
34
  export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
@@ -36,6 +41,7 @@ export interface WorkflowRunManifest {
36
41
  run_id: string;
37
42
  work_item_id?: string;
38
43
  work_context?: WorkflowWorkContext;
44
+ attempt_context?: WorkflowAttemptContext;
39
45
  name: string;
40
46
  started_at: string;
41
47
  script: { path: string; sha256: string };
@@ -109,6 +115,10 @@ export function readWorkflowRunManifest(coordRoot: string, runId: string): Workf
109
115
  (!manifest.work_item_id ||
110
116
  manifest.work_context.id !== manifest.work_item_id ||
111
117
  !isCanonicalWorkflowWorkContext(manifest.work_context))) ||
118
+ (manifest.attempt_context !== undefined &&
119
+ (!manifest.work_item_id ||
120
+ !manifest.work_context ||
121
+ !isCanonicalWorkflowAttemptContext(manifest.attempt_context))) ||
112
122
  typeof manifest.name !== "string" ||
113
123
  manifest.name.length === 0 ||
114
124
  manifest.name.length > 200 ||
@@ -21,6 +21,7 @@ import type { BillingMode, BillingProber } from "./billing.ts";
21
21
 
22
22
  export const WORKFLOW_PROOF_SCHEMA_VERSION = 1 as const;
23
23
  export const WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION = 1 as const;
24
+ export const WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION = 1 as const;
24
25
 
25
26
  export type EvidenceKind = "test" | "command" | "artifact" | "change" | "review" | "observation";
26
27
  export type EvidenceStatus = "passed" | "failed" | "observed" | "unknown";
@@ -157,6 +158,7 @@ export interface WorkflowProof {
157
158
  ended_at: string;
158
159
  duration_ms: number;
159
160
  work_context?: WorkflowWorkContext;
161
+ attempt_context?: WorkflowAttemptContext;
160
162
  objective?: string;
161
163
  error?: string;
162
164
  result?: ResultDigest;
@@ -304,6 +306,9 @@ export interface WorkflowContext {
304
306
  /** Frozen durable-work assignment for work-linked runs. Standalone and
305
307
  * legacy-resumed workflows have no work context. */
306
308
  work?: Readonly<WorkflowWorkContext>;
309
+ /** Frozen attempt identity and, on retry, a bounded synopsis of prior
310
+ * terminal evidence. Absent for standalone and legacy-resumed workflows. */
311
+ attempt?: Readonly<WorkflowAttemptContext>;
307
312
  /** Spawn one subagent; resolves to validated JSON (schema) or reply text. */
308
313
  agent: (prompt: string, opts?: AgentOpts) => Promise<unknown>;
309
314
  /** Run thunks with bounded concurrency; a rejected thunk resolves to null. */
@@ -340,6 +345,33 @@ export interface WorkflowWorkContext {
340
345
  readonly acceptance: readonly string[];
341
346
  }
342
347
 
348
+ export type WorkflowAttemptFailureCause =
349
+ | "workflow_error"
350
+ | "acceptance_unsatisfied"
351
+ | "acceptance_unknown"
352
+ | "lost";
353
+
354
+ export interface WorkflowAttemptUnresolvedCriterion {
355
+ readonly id: string;
356
+ readonly statement: string;
357
+ readonly status: "unsatisfied" | "unknown";
358
+ }
359
+
360
+ export interface WorkflowAttemptPriorContext {
361
+ readonly run_id: string;
362
+ readonly causes: readonly WorkflowAttemptFailureCause[];
363
+ readonly error?: string;
364
+ readonly acceptance?: Readonly<AcceptanceSummary>;
365
+ readonly unresolved: readonly WorkflowAttemptUnresolvedCriterion[];
366
+ }
367
+
368
+ export interface WorkflowAttemptContext {
369
+ readonly schema_version: typeof WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION;
370
+ readonly number: number;
371
+ readonly trigger: "initial" | "retry";
372
+ readonly prior?: Readonly<WorkflowAttemptPriorContext>;
373
+ }
374
+
343
375
  export interface EngineOpts {
344
376
  /** Repo root whose .harnery/ receives the run journal. */
345
377
  coordRoot: string;
@@ -365,6 +397,9 @@ export interface EngineOpts {
365
397
  /** Frozen assignment data supplied by a durable-work host. Requires the
366
398
  * matching `workItemId`; parked resume always uses the manifest copy. */
367
399
  workContext?: WorkflowWorkContext;
400
+ /** Frozen attempt data supplied by a durable-work host. Requires matching
401
+ * work item and work contexts; parked resume uses the manifest copy. */
402
+ attemptContext?: WorkflowAttemptContext;
368
403
  /** Total-agent ceiling for the run (default 50): the runaway backstop. */
369
404
  maxAgents?: number;
370
405
  /** Concurrent-subagent cap for parallel() (default 4). */
@@ -111,7 +111,16 @@ export function validateAgainstSchema(value: unknown, schema: StageSchema, path
111
111
  }
112
112
  }
113
113
 
114
- /** Strip accidental markdown code fences, then strict-parse JSON. */
114
+ /**
115
+ * Parse one schema-gated JSON value.
116
+ *
117
+ * Exact JSON and a whole fenced value remain the normal paths. Some harnesses
118
+ * prepend a short narration despite an explicit JSON-only contract. Accept
119
+ * that drift only when the reply contains exactly one unambiguous top-level
120
+ * object or array, the value reaches the end of the reply, and JSON.parse
121
+ * accepts it. Trailing prose, multiple values, and malformed candidates remain
122
+ * failures.
123
+ */
115
124
  export function parseStageOutput(text: string): { value?: unknown; error?: string } {
116
125
  const stripped = text
117
126
  .trim()
@@ -120,10 +129,75 @@ export function parseStageOutput(text: string): { value?: unknown; error?: strin
120
129
  try {
121
130
  return { value: JSON.parse(stripped) };
122
131
  } catch (err) {
132
+ const embedded = singleTrailingJsonContainer(stripped);
133
+ if (embedded !== undefined) return { value: embedded };
123
134
  return { error: `not valid JSON: ${(err as Error).message}` };
124
135
  }
125
136
  }
126
137
 
138
+ function singleTrailingJsonContainer(text: string): unknown | undefined {
139
+ const candidates: Array<{ end: number; value: unknown }> = [];
140
+ let start = -1;
141
+ let stack: string[] = [];
142
+ let inString = false;
143
+ let escaped = false;
144
+
145
+ for (let index = 0; index < text.length; index++) {
146
+ const char = text[index]!;
147
+ if (start === -1) {
148
+ if (char === "{" || char === "[") {
149
+ start = index;
150
+ stack = [char];
151
+ inString = false;
152
+ escaped = false;
153
+ }
154
+ continue;
155
+ }
156
+
157
+ if (inString) {
158
+ if (escaped) {
159
+ escaped = false;
160
+ } else if (char === "\\") {
161
+ escaped = true;
162
+ } else if (char === '"') {
163
+ inString = false;
164
+ }
165
+ continue;
166
+ }
167
+ if (char === '"') {
168
+ inString = true;
169
+ continue;
170
+ }
171
+ if (char === "{" || char === "[") {
172
+ stack.push(char);
173
+ continue;
174
+ }
175
+ if (char !== "}" && char !== "]") continue;
176
+
177
+ const expectedOpen = char === "}" ? "{" : "[";
178
+ if (stack.at(-1) !== expectedOpen) {
179
+ start = -1;
180
+ stack = [];
181
+ continue;
182
+ }
183
+ stack.pop();
184
+ if (stack.length > 0) continue;
185
+
186
+ const end = index + 1;
187
+ try {
188
+ candidates.push({ end, value: JSON.parse(text.slice(start, end)) });
189
+ } catch {
190
+ // A balanced brace pair in narration is not a JSON candidate.
191
+ }
192
+ start = -1;
193
+ }
194
+
195
+ if (candidates.length !== 1 || text.slice(candidates[0]!.end).trim() !== "") {
196
+ return undefined;
197
+ }
198
+ return candidates[0]!.value;
199
+ }
200
+
127
201
  function short(value: unknown): string {
128
202
  const s = JSON.stringify(value);
129
203
  return s === undefined ? String(value) : s.length > 60 ? `${s.slice(0, 60)}…` : s;