@signalridge/pi-workflows 1.3.2 → 1.5.0
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.
- package/CHANGELOG.md +140 -0
- package/README.md +93 -22
- package/package.json +5 -4
- package/skills/workflow-authoring/SKILL.md +171 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +4 -3
- package/skills/workflow-authoring/references/capability-details.md +28 -8
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/focused-recipes.md +1 -0
- package/skills/workflow-authoring/references/lifecycle.md +3 -3
- package/skills/workflow-authoring/references/pattern-selection.md +1 -0
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +12 -7
- package/skills/workflow-authoring/references/runtime.md +6 -5
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +39 -7
- package/src/engine.ts +582 -142
- package/src/index.ts +413 -154
- package/src/journal.ts +447 -169
- package/src/orchestrator.ts +478 -0
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +508 -167
- package/src/ui.ts +29 -4
package/src/journal.ts
CHANGED
|
@@ -1,47 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* journal.ts — durable journal for script-based workflow runs (schema
|
|
2
|
+
* journal.ts — durable journal for script-based workflow runs (schema v4).
|
|
3
3
|
*
|
|
4
4
|
* A script run is journaled as an ordered event stream in the session entry
|
|
5
5
|
* store. `run_created` carries the raw script, its hash, and its parsed meta
|
|
6
6
|
* (the runtime re-derives call indexes from the script itself, so no task
|
|
7
7
|
* array is persisted). Each completed `agent()`/`checkpoint()` call journals a
|
|
8
|
-
* `call_result` keyed by its lexical call index; a
|
|
8
|
+
* `call_result` keyed by its lexical call index; a nested workflow journals a
|
|
9
|
+
* `workflow_result` at its parent call index; a resume rebuilds the
|
|
9
10
|
* runtime's `resumeJournal` map from these entries and replays the longest
|
|
10
|
-
* unchanged prefix.
|
|
11
|
+
* unchanged prefix. Nested `workflow()` calls are represented by one
|
|
12
|
+
* `workflow_result` boundary in the parent namespace, so child calls can be
|
|
13
|
+
* replayed without re-spawning the whole nested run.
|
|
11
14
|
*
|
|
12
15
|
* The atomic recovery machinery (run/terminal/attempt recovery with generation
|
|
13
16
|
* rotations, quarantine of malformed runs, stale-generation rejection) is
|
|
14
17
|
* preserved from the declarative journal and re-keyed to call indexes, because
|
|
15
18
|
* interrupted managed spawns still need generation rotation on restore.
|
|
16
19
|
*
|
|
17
|
-
* Schema
|
|
18
|
-
*
|
|
19
|
-
* inherit
|
|
20
|
+
* Schema-v3 and older runs are quarantined rather than replayed — the call
|
|
21
|
+
* hash and metadata contracts changed, and a script run must not silently
|
|
22
|
+
* inherit incompatible bookkeeping.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import { createHash } from "node:crypto";
|
|
23
26
|
import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
|
|
24
|
-
import
|
|
27
|
+
import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
28
|
+
import {
|
|
29
|
+
MAX_CALLS_PER_WORKFLOW,
|
|
30
|
+
normalizeAgentRetries,
|
|
31
|
+
normalizeAgentTimeout,
|
|
32
|
+
normalizeConcurrency,
|
|
33
|
+
normalizeMaxAgents,
|
|
34
|
+
normalizeTokenBudget,
|
|
35
|
+
type WorkflowMeta,
|
|
36
|
+
} from "./runtime.js";
|
|
25
37
|
|
|
26
38
|
export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export
|
|
30
|
-
export type JournalSchemaVersion = 2 | 3;
|
|
39
|
+
/** Breaking journal contract: v3 facts are quarantined, never replayed. */
|
|
40
|
+
export const JOURNAL_SCHEMA_VERSION = 4;
|
|
41
|
+
export type JournalSchemaVersion = 4;
|
|
31
42
|
export const JOURNAL_TEXT_LIMIT = 8_000;
|
|
32
43
|
export const JOURNAL_SCRIPT_LIMIT = 200_000;
|
|
33
44
|
export const JOURNAL_ERROR_LIMIT = 2_000;
|
|
34
45
|
export const JOURNAL_ID_LIMIT = 256;
|
|
35
46
|
export const JOURNAL_TIMESTAMP_LIMIT = 8_640_000_000_000_000;
|
|
36
|
-
export const JOURNAL_RECOVERY_ID_PREFIX = "
|
|
47
|
+
export const JOURNAL_RECOVERY_ID_PREFIX = "r4-";
|
|
37
48
|
export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
|
|
38
49
|
/** Maximum number of distinct atomic recoveries retained while replaying one run. */
|
|
39
50
|
export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
|
|
40
|
-
/** A script run may issue up to 1000 agent()/checkpoint() calls. */
|
|
41
|
-
export const JOURNAL_CALL_LIMIT =
|
|
51
|
+
/** A script run may issue up to 1000 agent()/workflow()/checkpoint() calls. */
|
|
52
|
+
export const JOURNAL_CALL_LIMIT = MAX_CALLS_PER_WORKFLOW;
|
|
42
53
|
/** One atomic recovery event may rotate every active call in a run. */
|
|
43
54
|
export const JOURNAL_RECOVERY_NODE_LIMIT = JOURNAL_CALL_LIMIT;
|
|
44
|
-
const RECOVERY_ID_PATTERN = /^
|
|
55
|
+
const RECOVERY_ID_PATTERN = /^r4-[0-9a-f]{64}$/;
|
|
45
56
|
|
|
46
57
|
export class StaleJournalGenerationError extends Error {
|
|
47
58
|
constructor(message: string) {
|
|
@@ -86,12 +97,18 @@ const JOURNAL_KINDS = new Set([
|
|
|
86
97
|
"call_transition",
|
|
87
98
|
"call_attempt",
|
|
88
99
|
"call_result",
|
|
100
|
+
"workflow_result",
|
|
89
101
|
]);
|
|
90
102
|
|
|
91
103
|
export type WorkflowOwner = ManagedOwner;
|
|
92
104
|
|
|
105
|
+
/** The Agent tier one journaled call resolved to. */
|
|
106
|
+
export interface CallTierIdentity {
|
|
107
|
+
tier?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
/** A completed agent()/checkpoint() call, journaled for resume replay. */
|
|
94
|
-
export interface CallResult {
|
|
111
|
+
export interface CallResult extends CallTierIdentity {
|
|
95
112
|
status: "completed" | "failed" | "stopped";
|
|
96
113
|
agentId?: string;
|
|
97
114
|
attemptId?: string;
|
|
@@ -110,8 +127,18 @@ export interface ScriptRun {
|
|
|
110
127
|
script: string;
|
|
111
128
|
scriptHash: string;
|
|
112
129
|
meta: WorkflowMeta;
|
|
130
|
+
/** Frozen invocation input reused by automatic and manual resume. */
|
|
131
|
+
args?: unknown;
|
|
132
|
+
/** Explicit marker recording whether the current run was given args. */
|
|
133
|
+
frozenArgsPresent: boolean;
|
|
113
134
|
/** Number of the latest edited-script revision. */
|
|
114
135
|
revision?: number;
|
|
136
|
+
/**
|
|
137
|
+
* This run's script ships with the package. Frozen for the same reason
|
|
138
|
+
* `toolset` is: a resume must keep treating a shipped script's tier names as
|
|
139
|
+
* preferences, not as assertions about the user's catalogue.
|
|
140
|
+
*/
|
|
141
|
+
shippedScript?: boolean;
|
|
115
142
|
/** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
|
|
116
143
|
toolset?: string;
|
|
117
144
|
/** Frozen run params — persisted so resume after restore keeps original budget/scale. */
|
|
@@ -131,10 +158,16 @@ export interface ScriptRun {
|
|
|
131
158
|
attemptTracking?: boolean;
|
|
132
159
|
/** Call index → journaled result (the resume journal source). */
|
|
133
160
|
callResults: Record<string, CallResult>;
|
|
161
|
+
/** Call index → resolved tier identity for live and stopped calls. */
|
|
162
|
+
callTiers: Record<string, CallTierIdentity>;
|
|
163
|
+
/** Call index → latest accepted nested workflow result generation. */
|
|
164
|
+
workflowResultGenerations: Record<string, number>;
|
|
134
165
|
compactions: Record<string, number>;
|
|
135
166
|
startedAt: number;
|
|
136
167
|
updatedAt: number;
|
|
137
168
|
error?: string;
|
|
169
|
+
/** Bounded JSON-serializable terminal script value for restored inspection. */
|
|
170
|
+
finalResult?: unknown;
|
|
138
171
|
nonResumable?: boolean;
|
|
139
172
|
/** Terminal cleanup intent while active agents settle. */
|
|
140
173
|
terminalIntent?: "stop" | "failure";
|
|
@@ -149,7 +182,7 @@ interface JournalBase {
|
|
|
149
182
|
/** State observed immediately before an atomic recovery rotation. */
|
|
150
183
|
export type RecoverySourceStatus = CallStatus | "running";
|
|
151
184
|
|
|
152
|
-
export interface RecoveryRotation {
|
|
185
|
+
export interface RecoveryRotation extends CallTierIdentity {
|
|
153
186
|
nodeId: string;
|
|
154
187
|
sourceAttemptId: string;
|
|
155
188
|
sourceGeneration: number;
|
|
@@ -174,8 +207,6 @@ export interface RunRecoveryEvent extends JournalBase {
|
|
|
174
207
|
rotations: RecoveryRotation[];
|
|
175
208
|
terminalResults?: RecoveryTerminalResult[];
|
|
176
209
|
recoveryId: string;
|
|
177
|
-
/** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
|
|
178
|
-
legacyMigration?: true;
|
|
179
210
|
timestamp: number;
|
|
180
211
|
}
|
|
181
212
|
|
|
@@ -196,8 +227,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
|
|
|
196
227
|
nodeId: string;
|
|
197
228
|
branchGeneration: number;
|
|
198
229
|
rotation: RecoveryRotation;
|
|
199
|
-
/** Only used to migrate a legacy prefix ending after workflow_transition. */
|
|
200
|
-
legacyMigration?: true;
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
export type JournalEvent =
|
|
@@ -206,6 +235,10 @@ export type JournalEvent =
|
|
|
206
235
|
script: string;
|
|
207
236
|
scriptHash: string;
|
|
208
237
|
meta: WorkflowMeta;
|
|
238
|
+
args?: unknown;
|
|
239
|
+
/** Explicit args presence marker for deterministic resume. */
|
|
240
|
+
frozenArgsPresent: boolean;
|
|
241
|
+
shippedScript?: boolean;
|
|
209
242
|
toolset?: string;
|
|
210
243
|
frozenMaxAgents?: number;
|
|
211
244
|
frozenConcurrency?: number;
|
|
@@ -230,6 +263,7 @@ export type JournalEvent =
|
|
|
230
263
|
kind: "workflow_transition";
|
|
231
264
|
status: WorkflowStatus;
|
|
232
265
|
error?: string;
|
|
266
|
+
finalResult?: unknown;
|
|
233
267
|
terminalIntent?: "stop" | "failure";
|
|
234
268
|
})
|
|
235
269
|
| RunRecoveryEvent
|
|
@@ -239,6 +273,7 @@ export type JournalEvent =
|
|
|
239
273
|
kind: "call_transition";
|
|
240
274
|
nodeId: string;
|
|
241
275
|
status: CallStatus;
|
|
276
|
+
tier?: string;
|
|
242
277
|
agentId?: string;
|
|
243
278
|
attemptId?: string;
|
|
244
279
|
owner?: WorkflowOwner;
|
|
@@ -248,6 +283,7 @@ export type JournalEvent =
|
|
|
248
283
|
nodeId: string;
|
|
249
284
|
attemptId: string;
|
|
250
285
|
generation: number;
|
|
286
|
+
tier?: string;
|
|
251
287
|
owner?: WorkflowOwner;
|
|
252
288
|
})
|
|
253
289
|
| (JournalBase & {
|
|
@@ -260,6 +296,20 @@ export type JournalEvent =
|
|
|
260
296
|
storeDelta?: Record<string, unknown>;
|
|
261
297
|
attemptId?: string;
|
|
262
298
|
owner?: WorkflowOwner;
|
|
299
|
+
})
|
|
300
|
+
| (JournalBase & {
|
|
301
|
+
/** A completed nested workflow result, without managed-spawn attempt identity. */
|
|
302
|
+
kind: "workflow_result";
|
|
303
|
+
nodeId: string;
|
|
304
|
+
result: CallResult;
|
|
305
|
+
/** Monotonic nested-call generation used to fence changed/resumed children. */
|
|
306
|
+
generation: number;
|
|
307
|
+
/** Logical agents represented by this nested replay boundary. */
|
|
308
|
+
agentCount?: number;
|
|
309
|
+
/** Stable hash of the nested workflow name/script/args. */
|
|
310
|
+
callHash: string;
|
|
311
|
+
/** Store writes produced by the child, merged in child call order. */
|
|
312
|
+
storeDelta?: Record<string, unknown>;
|
|
263
313
|
});
|
|
264
314
|
|
|
265
315
|
export interface JournalWriter {
|
|
@@ -274,6 +324,8 @@ export interface SessionEntryLike {
|
|
|
274
324
|
|
|
275
325
|
export interface ReplayJournalOptions {
|
|
276
326
|
onInvalid?: (diagnostic: string) => void;
|
|
327
|
+
/** Internal observer for events that passed replay validation and were applied. */
|
|
328
|
+
onAccepted?: (event: JournalEvent) => void;
|
|
277
329
|
}
|
|
278
330
|
|
|
279
331
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -302,27 +354,49 @@ function parseCallIndex(value: unknown, label: string): string {
|
|
|
302
354
|
return index;
|
|
303
355
|
}
|
|
304
356
|
|
|
357
|
+
function parsePersistedValue(raw: unknown, label: string): unknown {
|
|
358
|
+
try {
|
|
359
|
+
const cloned = structuredClone(raw);
|
|
360
|
+
const serialized = JSON.stringify(cloned);
|
|
361
|
+
if (serialized === undefined) throw new Error(`${label} is not JSON-serializable`);
|
|
362
|
+
if (serialized.length > JOURNAL_SCRIPT_LIMIT) throw new Error(`${label} exceeds the persistence limit`);
|
|
363
|
+
return cloned;
|
|
364
|
+
} catch (error: unknown) {
|
|
365
|
+
throw new Error(error instanceof Error ? error.message : `${label} is not structured-cloneable`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
305
369
|
function parseMeta(raw: unknown): WorkflowMeta {
|
|
306
370
|
if (!isRecord(raw)) throw new Error("meta is invalid");
|
|
307
371
|
const name = boundedString(raw.name, "meta.name", 512);
|
|
308
372
|
const description = boundedString(raw.description, "meta.description", 100_000);
|
|
373
|
+
if (Object.hasOwn(raw, "model") || Object.hasOwn(raw, "thinking")) {
|
|
374
|
+
throw new Error("workflow journal meta model/thinking fields are unsupported");
|
|
375
|
+
}
|
|
309
376
|
const meta: WorkflowMeta = { name, description };
|
|
310
|
-
if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
|
|
311
377
|
if (raw.phases !== undefined) {
|
|
312
378
|
if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
|
|
313
379
|
meta.phases = raw.phases.map((phase, i) => {
|
|
314
380
|
if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
|
|
315
381
|
throw new Error(`meta.phases[${i}].title is invalid`);
|
|
316
382
|
}
|
|
383
|
+
if (Object.hasOwn(phase, "model") || Object.hasOwn(phase, "thinking")) {
|
|
384
|
+
throw new Error(`workflow journal phase ${i} model/thinking fields are unsupported`);
|
|
385
|
+
}
|
|
317
386
|
const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
|
|
318
387
|
if (phase.detail !== undefined) out.detail = String(phase.detail).slice(0, 2_000);
|
|
319
|
-
if (phase.model !== undefined) out.model = String(phase.model).slice(0, 512);
|
|
320
388
|
return out;
|
|
321
389
|
});
|
|
322
390
|
}
|
|
323
391
|
return meta;
|
|
324
392
|
}
|
|
325
393
|
|
|
394
|
+
function parseCallTierIdentity(raw: Record<string, unknown>, label: string): CallTierIdentity {
|
|
395
|
+
if (raw.tier === undefined) return {};
|
|
396
|
+
if (!isManagedAgentTier(raw.tier)) throw new Error(`${label}.tier is invalid`);
|
|
397
|
+
return { tier: raw.tier };
|
|
398
|
+
}
|
|
399
|
+
|
|
326
400
|
function parseResult(raw: unknown, label: string): CallResult {
|
|
327
401
|
if (!isRecord(raw)) throw new Error(`${label} is invalid`);
|
|
328
402
|
const status = raw.status;
|
|
@@ -334,10 +408,12 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
334
408
|
throw new Error(`${label} compactionCount is invalid`);
|
|
335
409
|
}
|
|
336
410
|
const updatedAt = validTimestamp(raw.updatedAt, label);
|
|
411
|
+
const tierIdentity = parseCallTierIdentity(raw, label);
|
|
337
412
|
const result: CallResult = {
|
|
338
413
|
status,
|
|
339
414
|
compactionCount,
|
|
340
415
|
updatedAt,
|
|
416
|
+
...tierIdentity,
|
|
341
417
|
...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
|
|
342
418
|
...(raw.attemptId === undefined
|
|
343
419
|
? {}
|
|
@@ -361,6 +437,17 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
361
437
|
return result;
|
|
362
438
|
}
|
|
363
439
|
|
|
440
|
+
function mergeCallResultTierIdentity(previous: CallTierIdentity | undefined, result: CallResult): CallResult {
|
|
441
|
+
return result.tier === undefined && previous?.tier !== undefined ? { ...result, tier: previous.tier } : result;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function priorCallTierIdentity(run: ScriptRun, nodeId: string): CallTierIdentity {
|
|
445
|
+
return {
|
|
446
|
+
...(run.callTiers[nodeId] ?? {}),
|
|
447
|
+
...(run.callResults[nodeId]?.tier === undefined ? {} : { tier: run.callResults[nodeId].tier }),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
364
451
|
function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
|
|
365
452
|
if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
|
|
366
453
|
const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
|
|
@@ -430,6 +517,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
430
517
|
if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
|
|
431
518
|
const owner = parseOwner(raw.owner, runId, nodeId);
|
|
432
519
|
if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
|
|
520
|
+
const tierIdentity = parseCallTierIdentity(raw, "rotation");
|
|
433
521
|
return {
|
|
434
522
|
nodeId,
|
|
435
523
|
sourceAttemptId,
|
|
@@ -438,6 +526,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
438
526
|
attemptId,
|
|
439
527
|
generation,
|
|
440
528
|
owner,
|
|
529
|
+
...tierIdentity,
|
|
441
530
|
...(raw.supersededAgentId === undefined
|
|
442
531
|
? {}
|
|
443
532
|
: { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
|
|
@@ -467,7 +556,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
|
|
|
467
556
|
raw.terminalResults === undefined
|
|
468
557
|
? undefined
|
|
469
558
|
: (() => {
|
|
470
|
-
if (!Array.isArray(raw.terminalResults)
|
|
559
|
+
if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
|
|
560
|
+
throw new Error("run recovery terminalResults are invalid");
|
|
561
|
+
}
|
|
471
562
|
return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
472
563
|
})();
|
|
473
564
|
const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
|
|
@@ -532,7 +623,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
|
|
|
532
623
|
nodeId: rotation.nodeId,
|
|
533
624
|
branchGeneration: raw.branchGeneration,
|
|
534
625
|
rotation,
|
|
535
|
-
...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
|
|
536
626
|
timestamp,
|
|
537
627
|
};
|
|
538
628
|
}
|
|
@@ -544,56 +634,38 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
|
|
|
544
634
|
}
|
|
545
635
|
|
|
546
636
|
function parseDuplicateRunRecovery(raw: Record<string, unknown>, run: ScriptRun): RunRecoveryEvent {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const rotations = raw.rotations.map((rotation) => parseRotation(rotation, run.runId));
|
|
552
|
-
const terminalResults =
|
|
553
|
-
raw.terminalResults === undefined
|
|
554
|
-
? undefined
|
|
555
|
-
: (() => {
|
|
556
|
-
if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
|
|
557
|
-
return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
558
|
-
})();
|
|
559
|
-
return {
|
|
560
|
-
kind: "run_recovery",
|
|
561
|
-
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
562
|
-
runId: run.runId,
|
|
563
|
-
status: "interrupted",
|
|
564
|
-
branchGeneration: run.updatedAt, // not meaningful for duplicates; fingerprint covers content
|
|
565
|
-
rotations,
|
|
566
|
-
...(terminalResults === undefined || terminalResults.length === 0 ? {} : { terminalResults }),
|
|
567
|
-
recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
|
|
568
|
-
timestamp: run.updatedAt,
|
|
569
|
-
};
|
|
637
|
+
// Duplicate detection must fingerprint the same semantic event as the normal
|
|
638
|
+
// parser. In particular, preserve branchGeneration and timestamp; replacing
|
|
639
|
+
// either with current run state turns an idempotent retry into a conflict.
|
|
640
|
+
return parseRunRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
|
|
570
641
|
}
|
|
571
642
|
|
|
572
643
|
function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: ScriptRun): TerminalRecoveryEvent {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
644
|
+
// Preserve every semantic field, including blocked nodes, error, branch
|
|
645
|
+
// generation, and timestamp, so an at-least-once append is truly idempotent.
|
|
646
|
+
return parseTerminalRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
|
|
650
|
+
if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
|
|
651
|
+
if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
|
|
652
|
+
const hasArgs = Object.hasOwn(raw, "args");
|
|
653
|
+
if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
|
|
654
|
+
throw new Error("frozen args presence marker requires workflow args");
|
|
576
655
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
runId: run.runId,
|
|
582
|
-
status: raw.status,
|
|
583
|
-
terminalIntent: raw.terminalIntent === "failure" ? "failure" : "stop",
|
|
584
|
-
branchGeneration: run.updatedAt,
|
|
585
|
-
terminalResults,
|
|
586
|
-
blockedNodeIds: [],
|
|
587
|
-
recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
|
|
588
|
-
timestamp: run.updatedAt,
|
|
589
|
-
};
|
|
656
|
+
if (!raw.frozenArgsPresent && hasArgs) {
|
|
657
|
+
throw new Error("frozen args presence marker conflicts with workflow args");
|
|
658
|
+
}
|
|
659
|
+
return raw.frozenArgsPresent;
|
|
590
660
|
}
|
|
591
661
|
|
|
592
662
|
function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
593
663
|
if (!isRecord(raw)) throw new Error("journal event is not an object");
|
|
594
664
|
if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
|
|
595
|
-
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION
|
|
596
|
-
throw new Error(
|
|
665
|
+
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
`journal schema version ${String(raw.schemaVersion)} is unsupported; expected ${JOURNAL_SCHEMA_VERSION}`,
|
|
668
|
+
);
|
|
597
669
|
}
|
|
598
670
|
const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
|
|
599
671
|
const timestamp = validTimestamp(raw.timestamp, "timestamp");
|
|
@@ -603,46 +675,74 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
603
675
|
const script = boundedString(raw.script, "script", JOURNAL_SCRIPT_LIMIT);
|
|
604
676
|
const scriptHash = boundedString(raw.scriptHash, "scriptHash", 128);
|
|
605
677
|
const meta = parseMeta(raw.meta);
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
678
|
+
const frozenArgsPresent = parseFrozenArgsPresence(raw);
|
|
679
|
+
const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
|
|
680
|
+
const toolset = Object.hasOwn(raw, "toolset") ? boundedString(raw.toolset, "toolset", 64) : undefined;
|
|
681
|
+
const shippedScript = (() => {
|
|
682
|
+
if (!Object.hasOwn(raw, "shippedScript")) return undefined;
|
|
683
|
+
if (typeof raw.shippedScript !== "boolean") throw new Error("shippedScript is invalid");
|
|
684
|
+
return raw.shippedScript;
|
|
685
|
+
})();
|
|
686
|
+
const frozenMaxAgents = (() => {
|
|
687
|
+
if (!Object.hasOwn(raw, "frozenMaxAgents")) return undefined;
|
|
688
|
+
if (typeof raw.frozenMaxAgents !== "number" || !Number.isFinite(raw.frozenMaxAgents) || raw.frozenMaxAgents < 1) {
|
|
689
|
+
throw new Error("frozenMaxAgents is invalid");
|
|
690
|
+
}
|
|
691
|
+
return normalizeMaxAgents(raw.frozenMaxAgents);
|
|
692
|
+
})();
|
|
693
|
+
const frozenConcurrency = (() => {
|
|
694
|
+
if (!Object.hasOwn(raw, "frozenConcurrency")) return undefined;
|
|
695
|
+
if (
|
|
696
|
+
typeof raw.frozenConcurrency !== "number" ||
|
|
697
|
+
!Number.isFinite(raw.frozenConcurrency) ||
|
|
698
|
+
raw.frozenConcurrency < 1
|
|
699
|
+
) {
|
|
700
|
+
throw new Error("frozenConcurrency is invalid");
|
|
701
|
+
}
|
|
702
|
+
return normalizeConcurrency(raw.frozenConcurrency);
|
|
703
|
+
})();
|
|
704
|
+
const frozenAgentRetries = (() => {
|
|
705
|
+
if (!Object.hasOwn(raw, "frozenAgentRetries")) return undefined;
|
|
706
|
+
if (
|
|
707
|
+
typeof raw.frozenAgentRetries !== "number" ||
|
|
708
|
+
!Number.isFinite(raw.frozenAgentRetries) ||
|
|
709
|
+
raw.frozenAgentRetries < 0
|
|
710
|
+
) {
|
|
711
|
+
throw new Error("frozenAgentRetries is invalid");
|
|
712
|
+
}
|
|
713
|
+
return normalizeAgentRetries(raw.frozenAgentRetries);
|
|
714
|
+
})();
|
|
715
|
+
const frozenTokenBudget = (() => {
|
|
716
|
+
if (!Object.hasOwn(raw, "frozenTokenBudget")) return undefined;
|
|
717
|
+
if (raw.frozenTokenBudget === null) return null;
|
|
718
|
+
if (
|
|
719
|
+
typeof raw.frozenTokenBudget !== "number" ||
|
|
720
|
+
!Number.isFinite(raw.frozenTokenBudget) ||
|
|
721
|
+
raw.frozenTokenBudget < 1
|
|
722
|
+
) {
|
|
723
|
+
throw new Error("frozenTokenBudget is invalid");
|
|
724
|
+
}
|
|
725
|
+
return normalizeTokenBudget(raw.frozenTokenBudget);
|
|
726
|
+
})();
|
|
727
|
+
const frozenAgentTimeoutMs = (() => {
|
|
728
|
+
if (!Object.hasOwn(raw, "frozenAgentTimeoutMs")) return undefined;
|
|
729
|
+
if (raw.frozenAgentTimeoutMs === null) return null;
|
|
730
|
+
if (
|
|
731
|
+
typeof raw.frozenAgentTimeoutMs !== "number" ||
|
|
732
|
+
!Number.isFinite(raw.frozenAgentTimeoutMs) ||
|
|
733
|
+
raw.frozenAgentTimeoutMs < 1
|
|
734
|
+
) {
|
|
735
|
+
throw new Error("frozenAgentTimeoutMs is invalid");
|
|
736
|
+
}
|
|
737
|
+
return normalizeAgentTimeout(raw.frozenAgentTimeoutMs);
|
|
738
|
+
})();
|
|
739
|
+
const frozenExcludeTools = (() => {
|
|
740
|
+
if (!Object.hasOwn(raw, "frozenExcludeTools")) return undefined;
|
|
741
|
+
if (!Array.isArray(raw.frozenExcludeTools) || !raw.frozenExcludeTools.every((v) => typeof v === "string")) {
|
|
742
|
+
throw new Error("frozenExcludeTools is invalid");
|
|
743
|
+
}
|
|
744
|
+
return [...raw.frozenExcludeTools] as string[];
|
|
745
|
+
})();
|
|
646
746
|
return {
|
|
647
747
|
kind: "run_created",
|
|
648
748
|
schemaVersion: raw.schemaVersion as JournalSchemaVersion,
|
|
@@ -650,6 +750,9 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
650
750
|
script,
|
|
651
751
|
scriptHash,
|
|
652
752
|
meta,
|
|
753
|
+
...(args === undefined ? {} : { args }),
|
|
754
|
+
frozenArgsPresent,
|
|
755
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
653
756
|
...(toolset ? { toolset } : {}),
|
|
654
757
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
655
758
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -695,12 +798,21 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
695
798
|
if (raw.terminalIntent !== undefined && raw.terminalIntent !== "stop" && raw.terminalIntent !== "failure") {
|
|
696
799
|
throw new Error("workflow terminal intent is invalid");
|
|
697
800
|
}
|
|
801
|
+
let finalResult: unknown;
|
|
802
|
+
if (raw.finalResult !== undefined) {
|
|
803
|
+
try {
|
|
804
|
+
finalResult = structuredClone(raw.finalResult);
|
|
805
|
+
} catch {
|
|
806
|
+
throw new Error("workflow finalResult is not structured-cloneable");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
698
809
|
return {
|
|
699
810
|
kind: "workflow_transition",
|
|
700
811
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
701
812
|
runId,
|
|
702
813
|
status: raw.status as WorkflowStatus,
|
|
703
814
|
timestamp,
|
|
815
|
+
...(finalResult === undefined ? {} : { finalResult }),
|
|
704
816
|
...(raw.error === undefined ? {} : { error: boundedString(raw.error, "error", JOURNAL_ERROR_LIMIT) }),
|
|
705
817
|
...(raw.terminalIntent === undefined ? {} : { terminalIntent: raw.terminalIntent }),
|
|
706
818
|
};
|
|
@@ -714,6 +826,63 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
714
826
|
}
|
|
715
827
|
|
|
716
828
|
const nodeId = parseCallIndex(raw.nodeId, "nodeId");
|
|
829
|
+
if (raw.kind === "workflow_result") {
|
|
830
|
+
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
|
|
831
|
+
if (raw.owner !== undefined || raw.attemptId !== undefined)
|
|
832
|
+
throw new Error("workflow result cannot carry a managed attempt");
|
|
833
|
+
const result = mergeCallResultTierIdentity(
|
|
834
|
+
priorCallTierIdentity(run, nodeId),
|
|
835
|
+
parseResult(raw.result, "workflow result"),
|
|
836
|
+
);
|
|
837
|
+
if (result.status !== "completed") throw new Error("workflow result must be completed");
|
|
838
|
+
if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
|
|
839
|
+
const generation = raw.generation;
|
|
840
|
+
if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 1 || generation > 1_000_000) {
|
|
841
|
+
throw new Error("workflow result generation is invalid");
|
|
842
|
+
}
|
|
843
|
+
const latestGeneration = run.workflowResultGenerations[nodeId] ?? 0;
|
|
844
|
+
if (generation < latestGeneration) {
|
|
845
|
+
throw new StaleJournalGenerationError("nested workflow result generation is stale");
|
|
846
|
+
}
|
|
847
|
+
if (
|
|
848
|
+
generation === latestGeneration &&
|
|
849
|
+
run.callResults[nodeId] &&
|
|
850
|
+
JSON.stringify(run.callResults[nodeId]) !== JSON.stringify(result)
|
|
851
|
+
) {
|
|
852
|
+
throw new Error("contradictory nested workflow result");
|
|
853
|
+
}
|
|
854
|
+
const agentCount =
|
|
855
|
+
raw.agentCount === undefined
|
|
856
|
+
? undefined
|
|
857
|
+
: typeof raw.agentCount === "number" &&
|
|
858
|
+
Number.isInteger(raw.agentCount) &&
|
|
859
|
+
raw.agentCount >= 0 &&
|
|
860
|
+
raw.agentCount <= JOURNAL_CALL_LIMIT
|
|
861
|
+
? raw.agentCount
|
|
862
|
+
: (() => {
|
|
863
|
+
throw new Error("workflow result agentCount is invalid");
|
|
864
|
+
})();
|
|
865
|
+
const callHash = boundedString(raw.callHash, "workflow result callHash", 128);
|
|
866
|
+
const storeDelta =
|
|
867
|
+
raw.storeDelta === undefined
|
|
868
|
+
? undefined
|
|
869
|
+
: (() => {
|
|
870
|
+
if (!isRecord(raw.storeDelta)) throw new Error("workflow result storeDelta is invalid");
|
|
871
|
+
return raw.storeDelta as Record<string, unknown>;
|
|
872
|
+
})();
|
|
873
|
+
return {
|
|
874
|
+
kind: "workflow_result",
|
|
875
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
876
|
+
runId,
|
|
877
|
+
nodeId,
|
|
878
|
+
result,
|
|
879
|
+
generation,
|
|
880
|
+
...(agentCount === undefined ? {} : { agentCount }),
|
|
881
|
+
callHash,
|
|
882
|
+
...(storeDelta === undefined ? {} : { storeDelta }),
|
|
883
|
+
timestamp,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
717
886
|
const owner = raw.owner === undefined ? undefined : parseOwner(raw.owner, runId, nodeId);
|
|
718
887
|
const rawAttemptId =
|
|
719
888
|
raw.attemptId === undefined ? undefined : boundedString(raw.attemptId, "attemptId", JOURNAL_ID_LIMIT);
|
|
@@ -740,6 +909,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
740
909
|
if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
|
|
741
910
|
throw new Error("call attempt does not identify its next generation");
|
|
742
911
|
}
|
|
912
|
+
const tierIdentity = parseCallTierIdentity(raw, "call attempt");
|
|
743
913
|
return {
|
|
744
914
|
kind: "call_attempt",
|
|
745
915
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
@@ -747,6 +917,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
747
917
|
nodeId,
|
|
748
918
|
attemptId,
|
|
749
919
|
generation,
|
|
920
|
+
...tierIdentity,
|
|
750
921
|
timestamp,
|
|
751
922
|
...(owner ? { owner } : {}),
|
|
752
923
|
};
|
|
@@ -755,12 +926,14 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
755
926
|
if (raw.kind === "call_transition") {
|
|
756
927
|
if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
|
|
757
928
|
const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
|
|
929
|
+
const tierIdentity = parseCallTierIdentity(raw, "call transition");
|
|
758
930
|
return {
|
|
759
931
|
kind: "call_transition",
|
|
760
932
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
761
933
|
runId,
|
|
762
934
|
nodeId,
|
|
763
935
|
status: raw.status as CallStatus,
|
|
936
|
+
...tierIdentity,
|
|
764
937
|
timestamp,
|
|
765
938
|
...(agentId ? { agentId } : {}),
|
|
766
939
|
...(attemptId ? { attemptId } : {}),
|
|
@@ -775,12 +948,14 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
775
948
|
if (result.attemptId && attemptId && result.attemptId !== attemptId) {
|
|
776
949
|
throw new StaleJournalGenerationError("call result payload references a superseded attempt");
|
|
777
950
|
}
|
|
951
|
+
const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
|
|
952
|
+
const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
|
|
778
953
|
return {
|
|
779
954
|
kind: "call_result",
|
|
780
955
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
781
956
|
runId,
|
|
782
957
|
nodeId,
|
|
783
|
-
result:
|
|
958
|
+
result: resultWithTierIdentity,
|
|
784
959
|
...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
|
|
785
960
|
...(raw.storeDelta === undefined
|
|
786
961
|
? {}
|
|
@@ -814,6 +989,8 @@ export function replayJournal(
|
|
|
814
989
|
const runs = new Map<string, ScriptRun>();
|
|
815
990
|
const quarantined = new Set<string>();
|
|
816
991
|
const removed = new Set<string>();
|
|
992
|
+
/** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
|
|
993
|
+
const creationFingerprints = new Map<string, string>();
|
|
817
994
|
/** Recovery identities are scoped to one replayed run and bounded explicitly. */
|
|
818
995
|
const seenRecoveries = new Map<string, Map<string, string>>();
|
|
819
996
|
let reported = false;
|
|
@@ -828,16 +1005,28 @@ export function replayJournal(
|
|
|
828
1005
|
const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
|
|
829
1006
|
if (rawRunId && quarantined.has(rawRunId)) continue;
|
|
830
1007
|
if (rawRunId && removed.has(rawRunId)) continue;
|
|
1008
|
+
// Set inside the guarded region, delivered outside it. An observer that
|
|
1009
|
+
// throws is reporting that the accepted facts contradict each other, which
|
|
1010
|
+
// is exactly the condition the caller needs to see — running it inside the
|
|
1011
|
+
// `try` would quarantine the run and swallow the diagnostic, turning a real
|
|
1012
|
+
// conflict into a silently missing cache entry. `finally` also delivers on
|
|
1013
|
+
// the `continue` paths, which a trailing statement would skip.
|
|
1014
|
+
let accepted: JournalEvent | undefined;
|
|
831
1015
|
try {
|
|
832
|
-
if (
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1016
|
+
if (
|
|
1017
|
+
isRecord(raw) &&
|
|
1018
|
+
raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
|
|
1019
|
+
raw.kind === "run_created" &&
|
|
1020
|
+
rawRunId &&
|
|
1021
|
+
runs.has(rawRunId)
|
|
1022
|
+
) {
|
|
1023
|
+
// Parse against an empty run map so every creation field participates in
|
|
1024
|
+
// the comparison, including frozen limits and attempt seeds. Comparing
|
|
1025
|
+
// against mutable run state would make a harmless retry conflict after
|
|
1026
|
+
// later call/recovery events have changed that state.
|
|
1027
|
+
const candidate = parseEvent(raw, new Map());
|
|
1028
|
+
const candidateFingerprint = JSON.stringify(candidate);
|
|
1029
|
+
if (creationFingerprints.get(rawRunId) === candidateFingerprint) continue;
|
|
841
1030
|
throw new Error("conflicting duplicate workflow run_created event");
|
|
842
1031
|
}
|
|
843
1032
|
if (
|
|
@@ -850,7 +1039,12 @@ export function replayJournal(
|
|
|
850
1039
|
throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
|
|
851
1040
|
}
|
|
852
1041
|
}
|
|
853
|
-
if (
|
|
1042
|
+
if (
|
|
1043
|
+
isRecord(raw) &&
|
|
1044
|
+
raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
|
|
1045
|
+
(raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
|
|
1046
|
+
rawRunId
|
|
1047
|
+
) {
|
|
854
1048
|
const run = runs.get(rawRunId);
|
|
855
1049
|
const seen = seenRecoveries.get(rawRunId);
|
|
856
1050
|
if (run && seen) {
|
|
@@ -873,7 +1067,9 @@ export function replayJournal(
|
|
|
873
1067
|
if (quarantined.has(event.runId)) continue;
|
|
874
1068
|
if (event.kind === "run_created") {
|
|
875
1069
|
applyJournalEvent(runs, event);
|
|
1070
|
+
creationFingerprints.set(event.runId, JSON.stringify(event));
|
|
876
1071
|
seenRecoveries.set(event.runId, new Map());
|
|
1072
|
+
accepted = event;
|
|
877
1073
|
continue;
|
|
878
1074
|
}
|
|
879
1075
|
if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
|
|
@@ -892,9 +1088,11 @@ export function replayJournal(
|
|
|
892
1088
|
applyJournalEvent(runs, event);
|
|
893
1089
|
seen.set(event.recoveryId, recoverySemanticFingerprint(event));
|
|
894
1090
|
seenRecoveries.set(event.runId, seen);
|
|
1091
|
+
accepted = event;
|
|
895
1092
|
continue;
|
|
896
1093
|
}
|
|
897
1094
|
applyJournalEvent(runs, event);
|
|
1095
|
+
accepted = event;
|
|
898
1096
|
if (event.kind === "run_removed") {
|
|
899
1097
|
removed.add(event.runId);
|
|
900
1098
|
}
|
|
@@ -903,9 +1101,12 @@ export function replayJournal(
|
|
|
903
1101
|
if (rawRunId) {
|
|
904
1102
|
quarantined.add(rawRunId);
|
|
905
1103
|
runs.delete(rawRunId);
|
|
1104
|
+
creationFingerprints.delete(rawRunId);
|
|
906
1105
|
seenRecoveries.delete(rawRunId);
|
|
907
1106
|
}
|
|
908
1107
|
report(error instanceof Error ? error.message : String(error));
|
|
1108
|
+
} finally {
|
|
1109
|
+
if (accepted) options.onAccepted?.(accepted);
|
|
909
1110
|
}
|
|
910
1111
|
}
|
|
911
1112
|
for (const [runId, run] of runs) {
|
|
@@ -914,6 +1115,7 @@ export function replayJournal(
|
|
|
914
1115
|
} catch (error: unknown) {
|
|
915
1116
|
runs.delete(runId);
|
|
916
1117
|
quarantined.add(runId);
|
|
1118
|
+
creationFingerprints.delete(runId);
|
|
917
1119
|
seenRecoveries.delete(runId);
|
|
918
1120
|
report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
919
1121
|
}
|
|
@@ -927,15 +1129,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
|
|
|
927
1129
|
run.attemptIds[rotation.nodeId] = rotation.attemptId;
|
|
928
1130
|
delete run.agentIds[rotation.nodeId];
|
|
929
1131
|
delete run.callResults[rotation.nodeId];
|
|
1132
|
+
const rotationTier = rotation.tier ?? run.callTiers[rotation.nodeId]?.tier;
|
|
1133
|
+
if (rotationTier !== undefined) run.callTiers[rotation.nodeId] = { tier: rotationTier };
|
|
930
1134
|
run.compactions[rotation.nodeId] = 0;
|
|
931
1135
|
run.callStatus[rotation.nodeId] = "running";
|
|
932
1136
|
}
|
|
933
1137
|
|
|
934
1138
|
function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
|
|
935
1139
|
run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
|
|
936
|
-
run
|
|
937
|
-
run.
|
|
938
|
-
|
|
1140
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, terminal.nodeId), terminal.result);
|
|
1141
|
+
run.callResults[terminal.nodeId] = result;
|
|
1142
|
+
run.callTiers[terminal.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1143
|
+
run.callStatus[terminal.nodeId] = result.status;
|
|
1144
|
+
if (result.agentId) run.agentIds[terminal.nodeId] = result.agentId;
|
|
939
1145
|
}
|
|
940
1146
|
|
|
941
1147
|
export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
|
|
@@ -983,7 +1189,10 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
983
1189
|
script: event.script,
|
|
984
1190
|
scriptHash: event.scriptHash,
|
|
985
1191
|
meta: event.meta,
|
|
1192
|
+
...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
|
|
1193
|
+
frozenArgsPresent: event.frozenArgsPresent,
|
|
986
1194
|
revision: 0,
|
|
1195
|
+
...(event.shippedScript === undefined ? {} : { shippedScript: event.shippedScript }),
|
|
987
1196
|
...(event.toolset ? { toolset: event.toolset } : {}),
|
|
988
1197
|
...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
|
|
989
1198
|
...(event.frozenConcurrency !== undefined ? { frozenConcurrency: event.frozenConcurrency } : {}),
|
|
@@ -998,6 +1207,8 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
998
1207
|
attemptIds,
|
|
999
1208
|
attemptTracking: event.attemptIds !== undefined,
|
|
1000
1209
|
callResults: {},
|
|
1210
|
+
callTiers: {},
|
|
1211
|
+
workflowResultGenerations: {},
|
|
1001
1212
|
compactions: {},
|
|
1002
1213
|
startedAt: event.timestamp,
|
|
1003
1214
|
updatedAt: event.timestamp,
|
|
@@ -1022,6 +1233,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1022
1233
|
case "workflow_transition":
|
|
1023
1234
|
run.status = event.status;
|
|
1024
1235
|
if (event.error !== undefined) run.error = event.error;
|
|
1236
|
+
if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
|
|
1025
1237
|
if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
|
|
1026
1238
|
break;
|
|
1027
1239
|
case "run_recovery":
|
|
@@ -1037,6 +1249,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1037
1249
|
run.attemptIds[event.nodeId] = event.attemptId;
|
|
1038
1250
|
delete run.agentIds[event.nodeId];
|
|
1039
1251
|
delete run.callResults[event.nodeId];
|
|
1252
|
+
if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
|
|
1040
1253
|
run.callStatus[event.nodeId] = "running";
|
|
1041
1254
|
run.compactions[event.nodeId] = 0;
|
|
1042
1255
|
break;
|
|
@@ -1044,28 +1257,52 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1044
1257
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1045
1258
|
throw new Error("call transition references a superseded attempt");
|
|
1046
1259
|
}
|
|
1047
|
-
run.
|
|
1260
|
+
if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
|
|
1261
|
+
// Identity-only transitions use status "running". A delayed retry must
|
|
1262
|
+
// not regress a terminal call result that was already replayed.
|
|
1263
|
+
if (
|
|
1264
|
+
event.status !== "running" ||
|
|
1265
|
+
run.callStatus[event.nodeId] === undefined ||
|
|
1266
|
+
run.callStatus[event.nodeId] === "running"
|
|
1267
|
+
) {
|
|
1268
|
+
run.callStatus[event.nodeId] = event.status;
|
|
1269
|
+
}
|
|
1048
1270
|
if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
|
|
1049
1271
|
break;
|
|
1050
|
-
case "
|
|
1272
|
+
case "workflow_result": {
|
|
1273
|
+
run.workflowResultGenerations[event.nodeId] = event.generation;
|
|
1274
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
|
|
1275
|
+
run.callResults[event.nodeId] = result;
|
|
1276
|
+
run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1277
|
+
run.callStatus[event.nodeId] = result.status;
|
|
1278
|
+
run.compactions[event.nodeId] = result.compactionCount;
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
case "call_result": {
|
|
1051
1282
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1052
1283
|
throw new Error("call result references a superseded attempt");
|
|
1053
1284
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
|
|
1057
|
-
) {
|
|
1285
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
|
|
1286
|
+
if (run.callResults[event.nodeId] && JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(result)) {
|
|
1058
1287
|
throw new Error("contradictory terminal call result");
|
|
1059
1288
|
}
|
|
1060
|
-
run.callResults[event.nodeId] =
|
|
1061
|
-
run.
|
|
1062
|
-
run.
|
|
1063
|
-
|
|
1289
|
+
run.callResults[event.nodeId] = result;
|
|
1290
|
+
run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1291
|
+
run.callStatus[event.nodeId] = result.status;
|
|
1292
|
+
run.compactions[event.nodeId] = Math.max(run.compactions[event.nodeId] ?? 0, result.compactionCount);
|
|
1293
|
+
if (result.agentId) run.agentIds[event.nodeId] = result.agentId;
|
|
1064
1294
|
break;
|
|
1295
|
+
}
|
|
1065
1296
|
}
|
|
1066
1297
|
}
|
|
1067
1298
|
|
|
1068
1299
|
function validateAggregate(run: ScriptRun): void {
|
|
1300
|
+
if (run.frozenArgsPresent === true && run.args === undefined) {
|
|
1301
|
+
throw new Error("frozen args presence marker requires workflow args");
|
|
1302
|
+
}
|
|
1303
|
+
if (run.frozenArgsPresent === false && run.args !== undefined) {
|
|
1304
|
+
throw new Error("frozen args presence marker conflicts with workflow args");
|
|
1305
|
+
}
|
|
1069
1306
|
for (const nodeId of Object.keys(run.attemptIds)) {
|
|
1070
1307
|
if (!Number.isInteger(run.attempts[nodeId]) || run.attempts[nodeId] < 1) {
|
|
1071
1308
|
throw new Error(`missing attempt generation for ${nodeId}`);
|
|
@@ -1074,8 +1311,11 @@ function validateAggregate(run: ScriptRun): void {
|
|
|
1074
1311
|
}
|
|
1075
1312
|
if (isTerminalWorkflowStatus(run.status)) {
|
|
1076
1313
|
const statuses = Object.values(run.callStatus);
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1314
|
+
if (
|
|
1315
|
+
run.status === "completed" &&
|
|
1316
|
+
statuses.some((status) => status !== "completed" && status !== "failed" && status !== "stopped")
|
|
1317
|
+
) {
|
|
1318
|
+
throw new Error("completed workflow has active calls");
|
|
1079
1319
|
}
|
|
1080
1320
|
if (
|
|
1081
1321
|
run.status === "stopped" &&
|
|
@@ -1126,44 +1366,82 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1126
1366
|
*
|
|
1127
1367
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1128
1368
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1129
|
-
* never replay the parent's entry. Every `call_result`
|
|
1130
|
-
* `callHash` is a replayable
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1369
|
+
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1370
|
+
* event that carries a `callHash` is a replayable fact. Entries from another
|
|
1371
|
+
* schema or without a hash are not replayable; they are ignored here even when
|
|
1372
|
+
* `replayJournal` retains them as non-replayable lifecycle history.
|
|
1133
1373
|
*/
|
|
1134
|
-
export function buildResumeJournal(
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
string
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1374
|
+
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1375
|
+
string,
|
|
1376
|
+
{
|
|
1377
|
+
index: number;
|
|
1378
|
+
runId?: string;
|
|
1379
|
+
hash: string;
|
|
1380
|
+
result: unknown;
|
|
1381
|
+
tier?: string;
|
|
1382
|
+
storeDelta?: Record<string, unknown>;
|
|
1383
|
+
generation?: number;
|
|
1384
|
+
agentCount?: number;
|
|
1385
|
+
}
|
|
1386
|
+
> {
|
|
1387
|
+
type ResumeEntry = {
|
|
1388
|
+
index: number;
|
|
1389
|
+
runId?: string;
|
|
1390
|
+
hash: string;
|
|
1391
|
+
result: unknown;
|
|
1392
|
+
tier?: string;
|
|
1393
|
+
storeDelta?: Record<string, unknown>;
|
|
1394
|
+
generation?: number;
|
|
1395
|
+
agentCount?: number;
|
|
1396
|
+
};
|
|
1397
|
+
const journal = new Map<string, ResumeEntry>();
|
|
1398
|
+
const journalRunIds = new Map<string, string>();
|
|
1399
|
+
|
|
1400
|
+
// Reuse the authoritative replay validator so stale call results from an
|
|
1401
|
+
// older attempt, terminal-after-lifecycle facts, and quarantined runs never
|
|
1402
|
+
// enter the cache merely because they appear later in the session branch.
|
|
1403
|
+
const replayedRuns = replayJournal(entries, {
|
|
1404
|
+
onAccepted: (event) => {
|
|
1405
|
+
if ((event.kind !== "call_result" && event.kind !== "workflow_result") || event.callHash === undefined) return;
|
|
1406
|
+
if (event.result.status !== "completed" || !Object.hasOwn(event.result, "result")) return;
|
|
1407
|
+
|
|
1408
|
+
const candidate: ResumeEntry = {
|
|
1409
|
+
index: Number(event.nodeId),
|
|
1410
|
+
runId: event.runId,
|
|
1411
|
+
hash: event.callHash,
|
|
1412
|
+
result: event.result.result,
|
|
1413
|
+
...(event.result.tier === undefined ? {} : { tier: event.result.tier }),
|
|
1414
|
+
...(event.kind === "workflow_result" ? { generation: event.generation } : {}),
|
|
1415
|
+
...(event.kind === "workflow_result" && event.agentCount !== undefined ? { agentCount: event.agentCount } : {}),
|
|
1416
|
+
...(event.storeDelta !== undefined && Object.keys(event.storeDelta).length > 0
|
|
1417
|
+
? { storeDelta: event.storeDelta }
|
|
1418
|
+
: {}),
|
|
1419
|
+
};
|
|
1420
|
+
const key = `${event.runId}:${event.nodeId}`;
|
|
1421
|
+
const existing = journal.get(key);
|
|
1422
|
+
if (candidate.generation !== undefined && existing?.generation !== undefined) {
|
|
1423
|
+
if (candidate.generation < existing.generation) return;
|
|
1424
|
+
// Same generation, same content is an at-least-once retry. Same
|
|
1425
|
+
// generation, different content is a contradiction `replayJournal` has
|
|
1426
|
+
// already rejected before accepting the second fact — reaching here
|
|
1427
|
+
// would mean the two validators disagree, so surface it rather than
|
|
1428
|
+
// quietly keeping whichever arrived first.
|
|
1429
|
+
if (candidate.generation === existing.generation) {
|
|
1430
|
+
if (JSON.stringify(existing) !== JSON.stringify(candidate))
|
|
1431
|
+
throw new Error("contradictory nested workflow result");
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
journal.set(key, candidate);
|
|
1436
|
+
journalRunIds.set(key, event.runId);
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
// replayJournal may quarantine a run after an accepted fact (for example,
|
|
1441
|
+
// when a later contradictory event invalidates the aggregate). Do not retain
|
|
1442
|
+
// cache entries belonging to that run.
|
|
1443
|
+
for (const [key, runId] of journalRunIds) {
|
|
1444
|
+
if (!replayedRuns.has(runId)) journal.delete(key);
|
|
1167
1445
|
}
|
|
1168
1446
|
return journal;
|
|
1169
1447
|
}
|