@signalridge/pi-workflows 1.4.0 → 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 +135 -0
- package/README.md +54 -12
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +11 -6
- package/skills/workflow-authoring/references/capabilities.md +3 -3
- package/skills/workflow-authoring/references/capability-details.md +7 -5
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +5 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +11 -4
- package/src/engine.ts +238 -58
- package/src/index.ts +292 -150
- package/src/journal.ts +292 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +176 -44
- package/src/ui.ts +29 -4
package/src/journal.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
* preserved from the declarative journal and re-keyed to call indexes, because
|
|
18
18
|
* interrupted managed spawns still need generation rotation on restore.
|
|
19
19
|
*
|
|
20
|
-
* Schema
|
|
21
|
-
*
|
|
22
|
-
* 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.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
|
|
27
|
+
import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
27
28
|
import {
|
|
28
29
|
MAX_CALLS_PER_WORKFLOW,
|
|
29
30
|
normalizeAgentRetries,
|
|
@@ -35,16 +36,15 @@ import {
|
|
|
35
36
|
} from "./runtime.js";
|
|
36
37
|
|
|
37
38
|
export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
export
|
|
41
|
-
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;
|
|
42
42
|
export const JOURNAL_TEXT_LIMIT = 8_000;
|
|
43
43
|
export const JOURNAL_SCRIPT_LIMIT = 200_000;
|
|
44
44
|
export const JOURNAL_ERROR_LIMIT = 2_000;
|
|
45
45
|
export const JOURNAL_ID_LIMIT = 256;
|
|
46
46
|
export const JOURNAL_TIMESTAMP_LIMIT = 8_640_000_000_000_000;
|
|
47
|
-
export const JOURNAL_RECOVERY_ID_PREFIX = "
|
|
47
|
+
export const JOURNAL_RECOVERY_ID_PREFIX = "r4-";
|
|
48
48
|
export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
|
|
49
49
|
/** Maximum number of distinct atomic recoveries retained while replaying one run. */
|
|
50
50
|
export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
|
|
@@ -52,7 +52,7 @@ export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
|
|
|
52
52
|
export const JOURNAL_CALL_LIMIT = MAX_CALLS_PER_WORKFLOW;
|
|
53
53
|
/** One atomic recovery event may rotate every active call in a run. */
|
|
54
54
|
export const JOURNAL_RECOVERY_NODE_LIMIT = JOURNAL_CALL_LIMIT;
|
|
55
|
-
const RECOVERY_ID_PATTERN = /^
|
|
55
|
+
const RECOVERY_ID_PATTERN = /^r4-[0-9a-f]{64}$/;
|
|
56
56
|
|
|
57
57
|
export class StaleJournalGenerationError extends Error {
|
|
58
58
|
constructor(message: string) {
|
|
@@ -88,7 +88,6 @@ const WORKFLOW_STATUS_VALUES = new Set<WorkflowStatus>([
|
|
|
88
88
|
const CALL_STATUS_VALUES = new Set<CallStatus>(["running", "completed", "failed", "stopped"]);
|
|
89
89
|
const JOURNAL_KINDS = new Set([
|
|
90
90
|
"run_created",
|
|
91
|
-
"run_args",
|
|
92
91
|
"run_revision",
|
|
93
92
|
"run_removed",
|
|
94
93
|
"workflow_transition",
|
|
@@ -103,8 +102,13 @@ const JOURNAL_KINDS = new Set([
|
|
|
103
102
|
|
|
104
103
|
export type WorkflowOwner = ManagedOwner;
|
|
105
104
|
|
|
105
|
+
/** The Agent tier one journaled call resolved to. */
|
|
106
|
+
export interface CallTierIdentity {
|
|
107
|
+
tier?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
106
110
|
/** A completed agent()/checkpoint() call, journaled for resume replay. */
|
|
107
|
-
export interface CallResult {
|
|
111
|
+
export interface CallResult extends CallTierIdentity {
|
|
108
112
|
status: "completed" | "failed" | "stopped";
|
|
109
113
|
agentId?: string;
|
|
110
114
|
attemptId?: string;
|
|
@@ -125,10 +129,16 @@ export interface ScriptRun {
|
|
|
125
129
|
meta: WorkflowMeta;
|
|
126
130
|
/** Frozen invocation input reused by automatic and manual resume. */
|
|
127
131
|
args?: unknown;
|
|
128
|
-
/** Explicit
|
|
129
|
-
frozenArgsPresent
|
|
132
|
+
/** Explicit marker recording whether the current run was given args. */
|
|
133
|
+
frozenArgsPresent: boolean;
|
|
130
134
|
/** Number of the latest edited-script revision. */
|
|
131
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;
|
|
132
142
|
/** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
|
|
133
143
|
toolset?: string;
|
|
134
144
|
/** Frozen run params — persisted so resume after restore keeps original budget/scale. */
|
|
@@ -148,6 +158,8 @@ export interface ScriptRun {
|
|
|
148
158
|
attemptTracking?: boolean;
|
|
149
159
|
/** Call index → journaled result (the resume journal source). */
|
|
150
160
|
callResults: Record<string, CallResult>;
|
|
161
|
+
/** Call index → resolved tier identity for live and stopped calls. */
|
|
162
|
+
callTiers: Record<string, CallTierIdentity>;
|
|
151
163
|
/** Call index → latest accepted nested workflow result generation. */
|
|
152
164
|
workflowResultGenerations: Record<string, number>;
|
|
153
165
|
compactions: Record<string, number>;
|
|
@@ -170,7 +182,7 @@ interface JournalBase {
|
|
|
170
182
|
/** State observed immediately before an atomic recovery rotation. */
|
|
171
183
|
export type RecoverySourceStatus = CallStatus | "running";
|
|
172
184
|
|
|
173
|
-
export interface RecoveryRotation {
|
|
185
|
+
export interface RecoveryRotation extends CallTierIdentity {
|
|
174
186
|
nodeId: string;
|
|
175
187
|
sourceAttemptId: string;
|
|
176
188
|
sourceGeneration: number;
|
|
@@ -195,8 +207,6 @@ export interface RunRecoveryEvent extends JournalBase {
|
|
|
195
207
|
rotations: RecoveryRotation[];
|
|
196
208
|
terminalResults?: RecoveryTerminalResult[];
|
|
197
209
|
recoveryId: string;
|
|
198
|
-
/** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
|
|
199
|
-
legacyMigration?: true;
|
|
200
210
|
timestamp: number;
|
|
201
211
|
}
|
|
202
212
|
|
|
@@ -217,8 +227,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
|
|
|
217
227
|
nodeId: string;
|
|
218
228
|
branchGeneration: number;
|
|
219
229
|
rotation: RecoveryRotation;
|
|
220
|
-
/** Only used to migrate a legacy prefix ending after workflow_transition. */
|
|
221
|
-
legacyMigration?: true;
|
|
222
230
|
}
|
|
223
231
|
|
|
224
232
|
export type JournalEvent =
|
|
@@ -228,8 +236,9 @@ export type JournalEvent =
|
|
|
228
236
|
scriptHash: string;
|
|
229
237
|
meta: WorkflowMeta;
|
|
230
238
|
args?: unknown;
|
|
231
|
-
/** Explicit args presence marker
|
|
232
|
-
frozenArgsPresent
|
|
239
|
+
/** Explicit args presence marker for deterministic resume. */
|
|
240
|
+
frozenArgsPresent: boolean;
|
|
241
|
+
shippedScript?: boolean;
|
|
233
242
|
toolset?: string;
|
|
234
243
|
frozenMaxAgents?: number;
|
|
235
244
|
frozenConcurrency?: number;
|
|
@@ -240,11 +249,6 @@ export type JournalEvent =
|
|
|
240
249
|
attempts?: Record<string, number>;
|
|
241
250
|
attemptIds?: Record<string, string>;
|
|
242
251
|
})
|
|
243
|
-
| (JournalBase & {
|
|
244
|
-
/** Canonical invocation args backfilled when resuming an older schema-v3 run. */
|
|
245
|
-
kind: "run_args";
|
|
246
|
-
args: unknown;
|
|
247
|
-
})
|
|
248
252
|
| (JournalBase & {
|
|
249
253
|
kind: "run_revision";
|
|
250
254
|
revision: number;
|
|
@@ -269,6 +273,7 @@ export type JournalEvent =
|
|
|
269
273
|
kind: "call_transition";
|
|
270
274
|
nodeId: string;
|
|
271
275
|
status: CallStatus;
|
|
276
|
+
tier?: string;
|
|
272
277
|
agentId?: string;
|
|
273
278
|
attemptId?: string;
|
|
274
279
|
owner?: WorkflowOwner;
|
|
@@ -278,6 +283,7 @@ export type JournalEvent =
|
|
|
278
283
|
nodeId: string;
|
|
279
284
|
attemptId: string;
|
|
280
285
|
generation: number;
|
|
286
|
+
tier?: string;
|
|
281
287
|
owner?: WorkflowOwner;
|
|
282
288
|
})
|
|
283
289
|
| (JournalBase & {
|
|
@@ -318,6 +324,8 @@ export interface SessionEntryLike {
|
|
|
318
324
|
|
|
319
325
|
export interface ReplayJournalOptions {
|
|
320
326
|
onInvalid?: (diagnostic: string) => void;
|
|
327
|
+
/** Internal observer for events that passed replay validation and were applied. */
|
|
328
|
+
onAccepted?: (event: JournalEvent) => void;
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -362,23 +370,33 @@ function parseMeta(raw: unknown): WorkflowMeta {
|
|
|
362
370
|
if (!isRecord(raw)) throw new Error("meta is invalid");
|
|
363
371
|
const name = boundedString(raw.name, "meta.name", 512);
|
|
364
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
|
+
}
|
|
365
376
|
const meta: WorkflowMeta = { name, description };
|
|
366
|
-
if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
|
|
367
377
|
if (raw.phases !== undefined) {
|
|
368
378
|
if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
|
|
369
379
|
meta.phases = raw.phases.map((phase, i) => {
|
|
370
380
|
if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
|
|
371
381
|
throw new Error(`meta.phases[${i}].title is invalid`);
|
|
372
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
|
+
}
|
|
373
386
|
const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
|
|
374
387
|
if (phase.detail !== undefined) out.detail = String(phase.detail).slice(0, 2_000);
|
|
375
|
-
if (phase.model !== undefined) out.model = String(phase.model).slice(0, 512);
|
|
376
388
|
return out;
|
|
377
389
|
});
|
|
378
390
|
}
|
|
379
391
|
return meta;
|
|
380
392
|
}
|
|
381
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
|
+
|
|
382
400
|
function parseResult(raw: unknown, label: string): CallResult {
|
|
383
401
|
if (!isRecord(raw)) throw new Error(`${label} is invalid`);
|
|
384
402
|
const status = raw.status;
|
|
@@ -390,10 +408,12 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
390
408
|
throw new Error(`${label} compactionCount is invalid`);
|
|
391
409
|
}
|
|
392
410
|
const updatedAt = validTimestamp(raw.updatedAt, label);
|
|
411
|
+
const tierIdentity = parseCallTierIdentity(raw, label);
|
|
393
412
|
const result: CallResult = {
|
|
394
413
|
status,
|
|
395
414
|
compactionCount,
|
|
396
415
|
updatedAt,
|
|
416
|
+
...tierIdentity,
|
|
397
417
|
...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
|
|
398
418
|
...(raw.attemptId === undefined
|
|
399
419
|
? {}
|
|
@@ -417,6 +437,17 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
417
437
|
return result;
|
|
418
438
|
}
|
|
419
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
|
+
|
|
420
451
|
function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
|
|
421
452
|
if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
|
|
422
453
|
const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
|
|
@@ -486,6 +517,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
486
517
|
if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
|
|
487
518
|
const owner = parseOwner(raw.owner, runId, nodeId);
|
|
488
519
|
if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
|
|
520
|
+
const tierIdentity = parseCallTierIdentity(raw, "rotation");
|
|
489
521
|
return {
|
|
490
522
|
nodeId,
|
|
491
523
|
sourceAttemptId,
|
|
@@ -494,6 +526,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
494
526
|
attemptId,
|
|
495
527
|
generation,
|
|
496
528
|
owner,
|
|
529
|
+
...tierIdentity,
|
|
497
530
|
...(raw.supersededAgentId === undefined
|
|
498
531
|
? {}
|
|
499
532
|
: { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
|
|
@@ -523,7 +556,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
|
|
|
523
556
|
raw.terminalResults === undefined
|
|
524
557
|
? undefined
|
|
525
558
|
: (() => {
|
|
526
|
-
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
|
+
}
|
|
527
562
|
return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
528
563
|
})();
|
|
529
564
|
const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
|
|
@@ -588,7 +623,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
|
|
|
588
623
|
nodeId: rotation.nodeId,
|
|
589
624
|
branchGeneration: raw.branchGeneration,
|
|
590
625
|
rotation,
|
|
591
|
-
...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
|
|
592
626
|
timestamp,
|
|
593
627
|
};
|
|
594
628
|
}
|
|
@@ -600,53 +634,20 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
|
|
|
600
634
|
}
|
|
601
635
|
|
|
602
636
|
function parseDuplicateRunRecovery(raw: Record<string, unknown>, run: ScriptRun): RunRecoveryEvent {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const rotations = raw.rotations.map((rotation) => parseRotation(rotation, run.runId));
|
|
608
|
-
const terminalResults =
|
|
609
|
-
raw.terminalResults === undefined
|
|
610
|
-
? undefined
|
|
611
|
-
: (() => {
|
|
612
|
-
if (!Array.isArray(raw.terminalResults)) throw new Error("run recovery terminalResults is invalid");
|
|
613
|
-
return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
614
|
-
})();
|
|
615
|
-
return {
|
|
616
|
-
kind: "run_recovery",
|
|
617
|
-
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
618
|
-
runId: run.runId,
|
|
619
|
-
status: "interrupted",
|
|
620
|
-
branchGeneration: run.updatedAt, // not meaningful for duplicates; fingerprint covers content
|
|
621
|
-
rotations,
|
|
622
|
-
...(terminalResults === undefined || terminalResults.length === 0 ? {} : { terminalResults }),
|
|
623
|
-
recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
|
|
624
|
-
timestamp: run.updatedAt,
|
|
625
|
-
};
|
|
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"));
|
|
626
641
|
}
|
|
627
642
|
|
|
628
643
|
function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: ScriptRun): TerminalRecoveryEvent {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
const terminalResults = raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
634
|
-
return {
|
|
635
|
-
kind: "terminal_recovery",
|
|
636
|
-
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
637
|
-
runId: run.runId,
|
|
638
|
-
status: raw.status,
|
|
639
|
-
terminalIntent: raw.terminalIntent === "failure" ? "failure" : "stop",
|
|
640
|
-
branchGeneration: run.updatedAt,
|
|
641
|
-
terminalResults,
|
|
642
|
-
blockedNodeIds: [],
|
|
643
|
-
recoveryId: boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT),
|
|
644
|
-
timestamp: run.updatedAt,
|
|
645
|
-
};
|
|
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"));
|
|
646
647
|
}
|
|
647
648
|
|
|
648
|
-
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean
|
|
649
|
-
if (raw.frozenArgsPresent === undefined)
|
|
649
|
+
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
|
|
650
|
+
if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
|
|
650
651
|
if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
|
|
651
652
|
const hasArgs = Object.hasOwn(raw, "args");
|
|
652
653
|
if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
|
|
@@ -658,15 +659,13 @@ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefi
|
|
|
658
659
|
return raw.frozenArgsPresent;
|
|
659
660
|
}
|
|
660
661
|
|
|
661
|
-
function parseEvent(
|
|
662
|
-
raw: unknown,
|
|
663
|
-
runs: Map<string, ScriptRun>,
|
|
664
|
-
legacyArgsBackfilled: ReadonlySet<string>,
|
|
665
|
-
): JournalEvent {
|
|
662
|
+
function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
666
663
|
if (!isRecord(raw)) throw new Error("journal event is not an object");
|
|
667
664
|
if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
|
|
668
|
-
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION
|
|
669
|
-
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
|
+
);
|
|
670
669
|
}
|
|
671
670
|
const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
|
|
672
671
|
const timestamp = validTimestamp(raw.timestamp, "timestamp");
|
|
@@ -678,46 +677,72 @@ function parseEvent(
|
|
|
678
677
|
const meta = parseMeta(raw.meta);
|
|
679
678
|
const frozenArgsPresent = parseFrozenArgsPresence(raw);
|
|
680
679
|
const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
|
|
681
|
-
const toolset =
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const frozenMaxAgents =
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
+
})();
|
|
721
746
|
return {
|
|
722
747
|
kind: "run_created",
|
|
723
748
|
schemaVersion: raw.schemaVersion as JournalSchemaVersion,
|
|
@@ -726,7 +751,8 @@ function parseEvent(
|
|
|
726
751
|
scriptHash,
|
|
727
752
|
meta,
|
|
728
753
|
...(args === undefined ? {} : { args }),
|
|
729
|
-
|
|
754
|
+
frozenArgsPresent,
|
|
755
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
730
756
|
...(toolset ? { toolset } : {}),
|
|
731
757
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
732
758
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -742,21 +768,6 @@ function parseEvent(
|
|
|
742
768
|
|
|
743
769
|
const run = runs.get(runId);
|
|
744
770
|
if (!run) throw new Error("journal event references an unknown run");
|
|
745
|
-
if (raw.kind === "run_args") {
|
|
746
|
-
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("run args schema version is unsupported");
|
|
747
|
-
if (!Object.hasOwn(raw, "args") || raw.args === undefined) throw new Error("run args fact is missing args");
|
|
748
|
-
if (run.schemaVersion !== JOURNAL_SCHEMA_VERSION || run.frozenArgsPresent !== undefined) {
|
|
749
|
-
throw new Error("run args fact cannot override frozen workflow args presence");
|
|
750
|
-
}
|
|
751
|
-
const args = parsePersistedValue(raw.args, "run args");
|
|
752
|
-
if (
|
|
753
|
-
run.args !== undefined &&
|
|
754
|
-
(!legacyArgsBackfilled.has(runId) || JSON.stringify(run.args) !== JSON.stringify(args))
|
|
755
|
-
) {
|
|
756
|
-
throw new Error("conflicting workflow run args");
|
|
757
|
-
}
|
|
758
|
-
return { kind: "run_args", schemaVersion: JOURNAL_SCHEMA_VERSION, runId, args, timestamp };
|
|
759
|
-
}
|
|
760
771
|
if (raw.kind === "run_revision") {
|
|
761
772
|
const revision = raw.revision;
|
|
762
773
|
if (typeof revision !== "number" || !Number.isInteger(revision) || revision <= (run.revision ?? 0))
|
|
@@ -819,7 +830,10 @@ function parseEvent(
|
|
|
819
830
|
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
|
|
820
831
|
if (raw.owner !== undefined || raw.attemptId !== undefined)
|
|
821
832
|
throw new Error("workflow result cannot carry a managed attempt");
|
|
822
|
-
const result =
|
|
833
|
+
const result = mergeCallResultTierIdentity(
|
|
834
|
+
priorCallTierIdentity(run, nodeId),
|
|
835
|
+
parseResult(raw.result, "workflow result"),
|
|
836
|
+
);
|
|
823
837
|
if (result.status !== "completed") throw new Error("workflow result must be completed");
|
|
824
838
|
if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
|
|
825
839
|
const generation = raw.generation;
|
|
@@ -895,6 +909,7 @@ function parseEvent(
|
|
|
895
909
|
if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
|
|
896
910
|
throw new Error("call attempt does not identify its next generation");
|
|
897
911
|
}
|
|
912
|
+
const tierIdentity = parseCallTierIdentity(raw, "call attempt");
|
|
898
913
|
return {
|
|
899
914
|
kind: "call_attempt",
|
|
900
915
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
@@ -902,6 +917,7 @@ function parseEvent(
|
|
|
902
917
|
nodeId,
|
|
903
918
|
attemptId,
|
|
904
919
|
generation,
|
|
920
|
+
...tierIdentity,
|
|
905
921
|
timestamp,
|
|
906
922
|
...(owner ? { owner } : {}),
|
|
907
923
|
};
|
|
@@ -910,12 +926,14 @@ function parseEvent(
|
|
|
910
926
|
if (raw.kind === "call_transition") {
|
|
911
927
|
if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
|
|
912
928
|
const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
|
|
929
|
+
const tierIdentity = parseCallTierIdentity(raw, "call transition");
|
|
913
930
|
return {
|
|
914
931
|
kind: "call_transition",
|
|
915
932
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
916
933
|
runId,
|
|
917
934
|
nodeId,
|
|
918
935
|
status: raw.status as CallStatus,
|
|
936
|
+
...tierIdentity,
|
|
919
937
|
timestamp,
|
|
920
938
|
...(agentId ? { agentId } : {}),
|
|
921
939
|
...(attemptId ? { attemptId } : {}),
|
|
@@ -930,12 +948,14 @@ function parseEvent(
|
|
|
930
948
|
if (result.attemptId && attemptId && result.attemptId !== attemptId) {
|
|
931
949
|
throw new StaleJournalGenerationError("call result payload references a superseded attempt");
|
|
932
950
|
}
|
|
951
|
+
const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
|
|
952
|
+
const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
|
|
933
953
|
return {
|
|
934
954
|
kind: "call_result",
|
|
935
955
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
936
956
|
runId,
|
|
937
957
|
nodeId,
|
|
938
|
-
result:
|
|
958
|
+
result: resultWithTierIdentity,
|
|
939
959
|
...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
|
|
940
960
|
...(raw.storeDelta === undefined
|
|
941
961
|
? {}
|
|
@@ -969,7 +989,8 @@ export function replayJournal(
|
|
|
969
989
|
const runs = new Map<string, ScriptRun>();
|
|
970
990
|
const quarantined = new Set<string>();
|
|
971
991
|
const removed = new Set<string>();
|
|
972
|
-
|
|
992
|
+
/** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
|
|
993
|
+
const creationFingerprints = new Map<string, string>();
|
|
973
994
|
/** Recovery identities are scoped to one replayed run and bounded explicitly. */
|
|
974
995
|
const seenRecoveries = new Map<string, Map<string, string>>();
|
|
975
996
|
let reported = false;
|
|
@@ -984,18 +1005,28 @@ export function replayJournal(
|
|
|
984
1005
|
const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
|
|
985
1006
|
if (rawRunId && quarantined.has(rawRunId)) continue;
|
|
986
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;
|
|
987
1015
|
try {
|
|
988
|
-
if (
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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;
|
|
999
1030
|
throw new Error("conflicting duplicate workflow run_created event");
|
|
1000
1031
|
}
|
|
1001
1032
|
if (
|
|
@@ -1008,7 +1039,12 @@ export function replayJournal(
|
|
|
1008
1039
|
throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
|
|
1009
1040
|
}
|
|
1010
1041
|
}
|
|
1011
|
-
if (
|
|
1042
|
+
if (
|
|
1043
|
+
isRecord(raw) &&
|
|
1044
|
+
raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
|
|
1045
|
+
(raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
|
|
1046
|
+
rawRunId
|
|
1047
|
+
) {
|
|
1012
1048
|
const run = runs.get(rawRunId);
|
|
1013
1049
|
const seen = seenRecoveries.get(rawRunId);
|
|
1014
1050
|
if (run && seen) {
|
|
@@ -1027,11 +1063,13 @@ export function replayJournal(
|
|
|
1027
1063
|
}
|
|
1028
1064
|
}
|
|
1029
1065
|
|
|
1030
|
-
const event = parseEvent(raw, runs
|
|
1066
|
+
const event = parseEvent(raw, runs);
|
|
1031
1067
|
if (quarantined.has(event.runId)) continue;
|
|
1032
1068
|
if (event.kind === "run_created") {
|
|
1033
1069
|
applyJournalEvent(runs, event);
|
|
1070
|
+
creationFingerprints.set(event.runId, JSON.stringify(event));
|
|
1034
1071
|
seenRecoveries.set(event.runId, new Map());
|
|
1072
|
+
accepted = event;
|
|
1035
1073
|
continue;
|
|
1036
1074
|
}
|
|
1037
1075
|
if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
|
|
@@ -1050,23 +1088,25 @@ export function replayJournal(
|
|
|
1050
1088
|
applyJournalEvent(runs, event);
|
|
1051
1089
|
seen.set(event.recoveryId, recoverySemanticFingerprint(event));
|
|
1052
1090
|
seenRecoveries.set(event.runId, seen);
|
|
1091
|
+
accepted = event;
|
|
1053
1092
|
continue;
|
|
1054
1093
|
}
|
|
1055
1094
|
applyJournalEvent(runs, event);
|
|
1056
|
-
|
|
1095
|
+
accepted = event;
|
|
1057
1096
|
if (event.kind === "run_removed") {
|
|
1058
1097
|
removed.add(event.runId);
|
|
1059
|
-
legacyArgsBackfilled.delete(event.runId);
|
|
1060
1098
|
}
|
|
1061
1099
|
} catch (error: unknown) {
|
|
1062
1100
|
if (error instanceof StaleJournalGenerationError) continue;
|
|
1063
1101
|
if (rawRunId) {
|
|
1064
1102
|
quarantined.add(rawRunId);
|
|
1065
1103
|
runs.delete(rawRunId);
|
|
1104
|
+
creationFingerprints.delete(rawRunId);
|
|
1066
1105
|
seenRecoveries.delete(rawRunId);
|
|
1067
|
-
legacyArgsBackfilled.delete(rawRunId);
|
|
1068
1106
|
}
|
|
1069
1107
|
report(error instanceof Error ? error.message : String(error));
|
|
1108
|
+
} finally {
|
|
1109
|
+
if (accepted) options.onAccepted?.(accepted);
|
|
1070
1110
|
}
|
|
1071
1111
|
}
|
|
1072
1112
|
for (const [runId, run] of runs) {
|
|
@@ -1075,6 +1115,7 @@ export function replayJournal(
|
|
|
1075
1115
|
} catch (error: unknown) {
|
|
1076
1116
|
runs.delete(runId);
|
|
1077
1117
|
quarantined.add(runId);
|
|
1118
|
+
creationFingerprints.delete(runId);
|
|
1078
1119
|
seenRecoveries.delete(runId);
|
|
1079
1120
|
report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1080
1121
|
}
|
|
@@ -1088,15 +1129,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
|
|
|
1088
1129
|
run.attemptIds[rotation.nodeId] = rotation.attemptId;
|
|
1089
1130
|
delete run.agentIds[rotation.nodeId];
|
|
1090
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 };
|
|
1091
1134
|
run.compactions[rotation.nodeId] = 0;
|
|
1092
1135
|
run.callStatus[rotation.nodeId] = "running";
|
|
1093
1136
|
}
|
|
1094
1137
|
|
|
1095
1138
|
function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
|
|
1096
1139
|
run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
|
|
1097
|
-
run
|
|
1098
|
-
run.
|
|
1099
|
-
|
|
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;
|
|
1100
1145
|
}
|
|
1101
1146
|
|
|
1102
1147
|
export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
|
|
@@ -1145,8 +1190,9 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1145
1190
|
scriptHash: event.scriptHash,
|
|
1146
1191
|
meta: event.meta,
|
|
1147
1192
|
...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
|
|
1148
|
-
|
|
1193
|
+
frozenArgsPresent: event.frozenArgsPresent,
|
|
1149
1194
|
revision: 0,
|
|
1195
|
+
...(event.shippedScript === undefined ? {} : { shippedScript: event.shippedScript }),
|
|
1150
1196
|
...(event.toolset ? { toolset: event.toolset } : {}),
|
|
1151
1197
|
...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
|
|
1152
1198
|
...(event.frozenConcurrency !== undefined ? { frozenConcurrency: event.frozenConcurrency } : {}),
|
|
@@ -1161,6 +1207,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1161
1207
|
attemptIds,
|
|
1162
1208
|
attemptTracking: event.attemptIds !== undefined,
|
|
1163
1209
|
callResults: {},
|
|
1210
|
+
callTiers: {},
|
|
1164
1211
|
workflowResultGenerations: {},
|
|
1165
1212
|
compactions: {},
|
|
1166
1213
|
startedAt: event.timestamp,
|
|
@@ -1177,9 +1224,6 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1177
1224
|
}
|
|
1178
1225
|
run.updatedAt = event.timestamp;
|
|
1179
1226
|
switch (event.kind) {
|
|
1180
|
-
case "run_args":
|
|
1181
|
-
run.args = structuredClone(event.args);
|
|
1182
|
-
break;
|
|
1183
1227
|
case "run_revision":
|
|
1184
1228
|
run.revision = event.revision;
|
|
1185
1229
|
run.script = event.script;
|
|
@@ -1205,6 +1249,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1205
1249
|
run.attemptIds[event.nodeId] = event.attemptId;
|
|
1206
1250
|
delete run.agentIds[event.nodeId];
|
|
1207
1251
|
delete run.callResults[event.nodeId];
|
|
1252
|
+
if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
|
|
1208
1253
|
run.callStatus[event.nodeId] = "running";
|
|
1209
1254
|
run.compactions[event.nodeId] = 0;
|
|
1210
1255
|
break;
|
|
@@ -1212,30 +1257,42 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1212
1257
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1213
1258
|
throw new Error("call transition references a superseded attempt");
|
|
1214
1259
|
}
|
|
1215
|
-
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
|
+
}
|
|
1216
1270
|
if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
|
|
1217
1271
|
break;
|
|
1218
|
-
case "workflow_result":
|
|
1272
|
+
case "workflow_result": {
|
|
1219
1273
|
run.workflowResultGenerations[event.nodeId] = event.generation;
|
|
1220
|
-
run
|
|
1221
|
-
run.
|
|
1222
|
-
run.
|
|
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;
|
|
1223
1279
|
break;
|
|
1224
|
-
|
|
1280
|
+
}
|
|
1281
|
+
case "call_result": {
|
|
1225
1282
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1226
1283
|
throw new Error("call result references a superseded attempt");
|
|
1227
1284
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
|
|
1231
|
-
) {
|
|
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)) {
|
|
1232
1287
|
throw new Error("contradictory terminal call result");
|
|
1233
1288
|
}
|
|
1234
|
-
run.callResults[event.nodeId] =
|
|
1235
|
-
run.
|
|
1236
|
-
run.
|
|
1237
|
-
|
|
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;
|
|
1238
1294
|
break;
|
|
1295
|
+
}
|
|
1239
1296
|
}
|
|
1240
1297
|
}
|
|
1241
1298
|
|
|
@@ -1310,9 +1367,9 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1310
1367
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1311
1368
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1312
1369
|
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1313
|
-
* event that carries a `callHash` is a replayable fact
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
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.
|
|
1316
1373
|
*/
|
|
1317
1374
|
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1318
1375
|
string,
|
|
@@ -1321,88 +1378,70 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1321
1378
|
runId?: string;
|
|
1322
1379
|
hash: string;
|
|
1323
1380
|
result: unknown;
|
|
1381
|
+
tier?: string;
|
|
1324
1382
|
storeDelta?: Record<string, unknown>;
|
|
1325
1383
|
generation?: number;
|
|
1326
1384
|
agentCount?: number;
|
|
1327
1385
|
}
|
|
1328
1386
|
> {
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
>();
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
? typeof raw.agentCount === "number" &&
|
|
1376
|
-
Number.isInteger(raw.agentCount) &&
|
|
1377
|
-
raw.agentCount >= 0 &&
|
|
1378
|
-
raw.agentCount <= JOURNAL_CALL_LIMIT
|
|
1379
|
-
? raw.agentCount
|
|
1380
|
-
: undefined
|
|
1381
|
-
: undefined;
|
|
1382
|
-
if (raw.kind === "workflow_result" && raw.agentCount !== undefined && agentCount === undefined) continue;
|
|
1383
|
-
const candidate = {
|
|
1384
|
-
index: Number(nodeId),
|
|
1385
|
-
runId,
|
|
1386
|
-
hash: raw.callHash,
|
|
1387
|
-
result: result.result,
|
|
1388
|
-
...(generation === undefined ? {} : { generation }),
|
|
1389
|
-
...(agentCount === undefined ? {} : { agentCount }),
|
|
1390
|
-
...(isRecord(raw.storeDelta) && Object.keys(raw.storeDelta).length > 0
|
|
1391
|
-
? { storeDelta: raw.storeDelta as Record<string, unknown> }
|
|
1392
|
-
: {}),
|
|
1393
|
-
};
|
|
1394
|
-
const key = `${runId}:${nodeId}`;
|
|
1395
|
-
const existing = journal.get(key);
|
|
1396
|
-
if (generation !== undefined && existing?.generation !== undefined) {
|
|
1397
|
-
if (generation < existing.generation) continue;
|
|
1398
|
-
if (generation === existing.generation) {
|
|
1399
|
-
if (JSON.stringify(existing) !== JSON.stringify(candidate)) {
|
|
1400
|
-
throw new Error("contradictory nested workflow result");
|
|
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;
|
|
1401
1433
|
}
|
|
1402
|
-
continue;
|
|
1403
1434
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
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);
|
|
1406
1445
|
}
|
|
1407
1446
|
return journal;
|
|
1408
1447
|
}
|