@signalridge/pi-workflows 1.4.0 → 1.6.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 +249 -0
- package/README.md +193 -15
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +23 -7
- package/skills/workflow-authoring/references/capabilities.md +6 -6
- package/skills/workflow-authoring/references/capability-details.md +16 -6
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/quality-helpers.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +6 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
- 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 +28 -6
- package/src/engine.ts +248 -58
- package/src/index.ts +457 -173
- package/src/journal.ts +278 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +429 -54
- package/src/strengths.ts +93 -0
- package/src/ui.ts +29 -4
- package/src/workflow-settings.ts +95 -1
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,8 +129,8 @@ 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;
|
|
132
136
|
/** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
|
|
@@ -148,6 +152,8 @@ export interface ScriptRun {
|
|
|
148
152
|
attemptTracking?: boolean;
|
|
149
153
|
/** Call index → journaled result (the resume journal source). */
|
|
150
154
|
callResults: Record<string, CallResult>;
|
|
155
|
+
/** Call index → resolved tier identity for live and stopped calls. */
|
|
156
|
+
callTiers: Record<string, CallTierIdentity>;
|
|
151
157
|
/** Call index → latest accepted nested workflow result generation. */
|
|
152
158
|
workflowResultGenerations: Record<string, number>;
|
|
153
159
|
compactions: Record<string, number>;
|
|
@@ -170,7 +176,7 @@ interface JournalBase {
|
|
|
170
176
|
/** State observed immediately before an atomic recovery rotation. */
|
|
171
177
|
export type RecoverySourceStatus = CallStatus | "running";
|
|
172
178
|
|
|
173
|
-
export interface RecoveryRotation {
|
|
179
|
+
export interface RecoveryRotation extends CallTierIdentity {
|
|
174
180
|
nodeId: string;
|
|
175
181
|
sourceAttemptId: string;
|
|
176
182
|
sourceGeneration: number;
|
|
@@ -195,8 +201,6 @@ export interface RunRecoveryEvent extends JournalBase {
|
|
|
195
201
|
rotations: RecoveryRotation[];
|
|
196
202
|
terminalResults?: RecoveryTerminalResult[];
|
|
197
203
|
recoveryId: string;
|
|
198
|
-
/** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
|
|
199
|
-
legacyMigration?: true;
|
|
200
204
|
timestamp: number;
|
|
201
205
|
}
|
|
202
206
|
|
|
@@ -217,8 +221,6 @@ export interface AttemptRecoveryEvent extends JournalBase {
|
|
|
217
221
|
nodeId: string;
|
|
218
222
|
branchGeneration: number;
|
|
219
223
|
rotation: RecoveryRotation;
|
|
220
|
-
/** Only used to migrate a legacy prefix ending after workflow_transition. */
|
|
221
|
-
legacyMigration?: true;
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
export type JournalEvent =
|
|
@@ -228,8 +230,8 @@ export type JournalEvent =
|
|
|
228
230
|
scriptHash: string;
|
|
229
231
|
meta: WorkflowMeta;
|
|
230
232
|
args?: unknown;
|
|
231
|
-
/** Explicit args presence marker
|
|
232
|
-
frozenArgsPresent
|
|
233
|
+
/** Explicit args presence marker for deterministic resume. */
|
|
234
|
+
frozenArgsPresent: boolean;
|
|
233
235
|
toolset?: string;
|
|
234
236
|
frozenMaxAgents?: number;
|
|
235
237
|
frozenConcurrency?: number;
|
|
@@ -240,11 +242,6 @@ export type JournalEvent =
|
|
|
240
242
|
attempts?: Record<string, number>;
|
|
241
243
|
attemptIds?: Record<string, string>;
|
|
242
244
|
})
|
|
243
|
-
| (JournalBase & {
|
|
244
|
-
/** Canonical invocation args backfilled when resuming an older schema-v3 run. */
|
|
245
|
-
kind: "run_args";
|
|
246
|
-
args: unknown;
|
|
247
|
-
})
|
|
248
245
|
| (JournalBase & {
|
|
249
246
|
kind: "run_revision";
|
|
250
247
|
revision: number;
|
|
@@ -269,6 +266,7 @@ export type JournalEvent =
|
|
|
269
266
|
kind: "call_transition";
|
|
270
267
|
nodeId: string;
|
|
271
268
|
status: CallStatus;
|
|
269
|
+
tier?: string;
|
|
272
270
|
agentId?: string;
|
|
273
271
|
attemptId?: string;
|
|
274
272
|
owner?: WorkflowOwner;
|
|
@@ -278,6 +276,7 @@ export type JournalEvent =
|
|
|
278
276
|
nodeId: string;
|
|
279
277
|
attemptId: string;
|
|
280
278
|
generation: number;
|
|
279
|
+
tier?: string;
|
|
281
280
|
owner?: WorkflowOwner;
|
|
282
281
|
})
|
|
283
282
|
| (JournalBase & {
|
|
@@ -318,6 +317,8 @@ export interface SessionEntryLike {
|
|
|
318
317
|
|
|
319
318
|
export interface ReplayJournalOptions {
|
|
320
319
|
onInvalid?: (diagnostic: string) => void;
|
|
320
|
+
/** Internal observer for events that passed replay validation and were applied. */
|
|
321
|
+
onAccepted?: (event: JournalEvent) => void;
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -362,23 +363,33 @@ function parseMeta(raw: unknown): WorkflowMeta {
|
|
|
362
363
|
if (!isRecord(raw)) throw new Error("meta is invalid");
|
|
363
364
|
const name = boundedString(raw.name, "meta.name", 512);
|
|
364
365
|
const description = boundedString(raw.description, "meta.description", 100_000);
|
|
366
|
+
if (Object.hasOwn(raw, "model") || Object.hasOwn(raw, "thinking")) {
|
|
367
|
+
throw new Error("workflow journal meta model/thinking fields are unsupported");
|
|
368
|
+
}
|
|
365
369
|
const meta: WorkflowMeta = { name, description };
|
|
366
|
-
if (raw.model !== undefined) meta.model = boundedString(raw.model, "meta.model", 512);
|
|
367
370
|
if (raw.phases !== undefined) {
|
|
368
371
|
if (!Array.isArray(raw.phases)) throw new Error("meta.phases must be an array");
|
|
369
372
|
meta.phases = raw.phases.map((phase, i) => {
|
|
370
373
|
if (!isRecord(phase) || typeof phase.title !== "string" || phase.title.length === 0) {
|
|
371
374
|
throw new Error(`meta.phases[${i}].title is invalid`);
|
|
372
375
|
}
|
|
376
|
+
if (Object.hasOwn(phase, "model") || Object.hasOwn(phase, "thinking")) {
|
|
377
|
+
throw new Error(`workflow journal phase ${i} model/thinking fields are unsupported`);
|
|
378
|
+
}
|
|
373
379
|
const out: NonNullable<WorkflowMeta["phases"]>[number] = { title: phase.title.slice(0, 512) };
|
|
374
380
|
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
381
|
return out;
|
|
377
382
|
});
|
|
378
383
|
}
|
|
379
384
|
return meta;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
function parseCallTierIdentity(raw: Record<string, unknown>, label: string): CallTierIdentity {
|
|
388
|
+
if (raw.tier === undefined) return {};
|
|
389
|
+
if (!isManagedAgentTier(raw.tier)) throw new Error(`${label}.tier is invalid`);
|
|
390
|
+
return { tier: raw.tier };
|
|
391
|
+
}
|
|
392
|
+
|
|
382
393
|
function parseResult(raw: unknown, label: string): CallResult {
|
|
383
394
|
if (!isRecord(raw)) throw new Error(`${label} is invalid`);
|
|
384
395
|
const status = raw.status;
|
|
@@ -390,10 +401,12 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
390
401
|
throw new Error(`${label} compactionCount is invalid`);
|
|
391
402
|
}
|
|
392
403
|
const updatedAt = validTimestamp(raw.updatedAt, label);
|
|
404
|
+
const tierIdentity = parseCallTierIdentity(raw, label);
|
|
393
405
|
const result: CallResult = {
|
|
394
406
|
status,
|
|
395
407
|
compactionCount,
|
|
396
408
|
updatedAt,
|
|
409
|
+
...tierIdentity,
|
|
397
410
|
...(raw.agentId === undefined ? {} : { agentId: boundedString(raw.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
|
|
398
411
|
...(raw.attemptId === undefined
|
|
399
412
|
? {}
|
|
@@ -417,6 +430,17 @@ function parseResult(raw: unknown, label: string): CallResult {
|
|
|
417
430
|
return result;
|
|
418
431
|
}
|
|
419
432
|
|
|
433
|
+
function mergeCallResultTierIdentity(previous: CallTierIdentity | undefined, result: CallResult): CallResult {
|
|
434
|
+
return result.tier === undefined && previous?.tier !== undefined ? { ...result, tier: previous.tier } : result;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function priorCallTierIdentity(run: ScriptRun, nodeId: string): CallTierIdentity {
|
|
438
|
+
return {
|
|
439
|
+
...(run.callTiers[nodeId] ?? {}),
|
|
440
|
+
...(run.callResults[nodeId]?.tier === undefined ? {} : { tier: run.callResults[nodeId].tier }),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
420
444
|
function parseOwner(raw: unknown, runId: string, nodeId: string): WorkflowOwner {
|
|
421
445
|
if (!isRecord(raw) || raw.extension !== "pi-workflows") throw new Error("journal owner is invalid");
|
|
422
446
|
const ownerRunId = boundedString(raw.runId, "owner.runId", JOURNAL_ID_LIMIT);
|
|
@@ -486,6 +510,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
486
510
|
if (generation <= sourceGeneration) throw new Error("rotation generation must exceed its source");
|
|
487
511
|
const owner = parseOwner(raw.owner, runId, nodeId);
|
|
488
512
|
if (owner.attemptId !== attemptId) throw new Error("rotation owner does not match its attempt");
|
|
513
|
+
const tierIdentity = parseCallTierIdentity(raw, "rotation");
|
|
489
514
|
return {
|
|
490
515
|
nodeId,
|
|
491
516
|
sourceAttemptId,
|
|
@@ -494,6 +519,7 @@ function parseRotation(raw: unknown, runId: string): RecoveryRotation {
|
|
|
494
519
|
attemptId,
|
|
495
520
|
generation,
|
|
496
521
|
owner,
|
|
522
|
+
...tierIdentity,
|
|
497
523
|
...(raw.supersededAgentId === undefined
|
|
498
524
|
? {}
|
|
499
525
|
: { supersededAgentId: boundedString(raw.supersededAgentId, "rotation.supersededAgentId", JOURNAL_ID_LIMIT) }),
|
|
@@ -523,7 +549,9 @@ function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestam
|
|
|
523
549
|
raw.terminalResults === undefined
|
|
524
550
|
? undefined
|
|
525
551
|
: (() => {
|
|
526
|
-
if (!Array.isArray(raw.terminalResults)
|
|
552
|
+
if (!Array.isArray(raw.terminalResults) || raw.terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
|
|
553
|
+
throw new Error("run recovery terminalResults are invalid");
|
|
554
|
+
}
|
|
527
555
|
return raw.terminalResults.map((terminal) => parseTerminalResult(terminal, run.runId));
|
|
528
556
|
})();
|
|
529
557
|
const recoveryId = boundedString(raw.recoveryId, "recoveryId", JOURNAL_RECOVERY_ID_LIMIT);
|
|
@@ -588,7 +616,6 @@ function parseAttemptRecovery(raw: Record<string, unknown>, run: ScriptRun, time
|
|
|
588
616
|
nodeId: rotation.nodeId,
|
|
589
617
|
branchGeneration: raw.branchGeneration,
|
|
590
618
|
rotation,
|
|
591
|
-
...(raw.legacyMigration === true ? { legacyMigration: true } : {}),
|
|
592
619
|
timestamp,
|
|
593
620
|
};
|
|
594
621
|
}
|
|
@@ -600,53 +627,20 @@ export function recoverySemanticFingerprint(event: RunRecoveryEvent | TerminalRe
|
|
|
600
627
|
}
|
|
601
628
|
|
|
602
629
|
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
|
-
};
|
|
630
|
+
// Duplicate detection must fingerprint the same semantic event as the normal
|
|
631
|
+
// parser. In particular, preserve branchGeneration and timestamp; replacing
|
|
632
|
+
// either with current run state turns an idempotent retry into a conflict.
|
|
633
|
+
return parseRunRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
|
|
626
634
|
}
|
|
627
635
|
|
|
628
636
|
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
|
-
};
|
|
637
|
+
// Preserve every semantic field, including blocked nodes, error, branch
|
|
638
|
+
// generation, and timestamp, so an at-least-once append is truly idempotent.
|
|
639
|
+
return parseTerminalRecovery(raw, run, validTimestamp(raw.timestamp, "timestamp"));
|
|
646
640
|
}
|
|
647
641
|
|
|
648
|
-
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean
|
|
649
|
-
if (raw.frozenArgsPresent === undefined)
|
|
642
|
+
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean {
|
|
643
|
+
if (raw.frozenArgsPresent === undefined) throw new Error("frozen args presence marker is required");
|
|
650
644
|
if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
|
|
651
645
|
const hasArgs = Object.hasOwn(raw, "args");
|
|
652
646
|
if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
|
|
@@ -658,15 +652,13 @@ function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefi
|
|
|
658
652
|
return raw.frozenArgsPresent;
|
|
659
653
|
}
|
|
660
654
|
|
|
661
|
-
function parseEvent(
|
|
662
|
-
raw: unknown,
|
|
663
|
-
runs: Map<string, ScriptRun>,
|
|
664
|
-
legacyArgsBackfilled: ReadonlySet<string>,
|
|
665
|
-
): JournalEvent {
|
|
655
|
+
function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
666
656
|
if (!isRecord(raw)) throw new Error("journal event is not an object");
|
|
667
657
|
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(
|
|
658
|
+
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) {
|
|
659
|
+
throw new Error(
|
|
660
|
+
`journal schema version ${String(raw.schemaVersion)} is unsupported; expected ${JOURNAL_SCHEMA_VERSION}`,
|
|
661
|
+
);
|
|
670
662
|
}
|
|
671
663
|
const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
|
|
672
664
|
const timestamp = validTimestamp(raw.timestamp, "timestamp");
|
|
@@ -678,46 +670,67 @@ function parseEvent(
|
|
|
678
670
|
const meta = parseMeta(raw.meta);
|
|
679
671
|
const frozenArgsPresent = parseFrozenArgsPresence(raw);
|
|
680
672
|
const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
|
|
681
|
-
const toolset =
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
raw.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
673
|
+
const toolset = Object.hasOwn(raw, "toolset") ? boundedString(raw.toolset, "toolset", 64) : undefined;
|
|
674
|
+
const frozenMaxAgents = (() => {
|
|
675
|
+
if (!Object.hasOwn(raw, "frozenMaxAgents")) return undefined;
|
|
676
|
+
if (typeof raw.frozenMaxAgents !== "number" || !Number.isFinite(raw.frozenMaxAgents) || raw.frozenMaxAgents < 1) {
|
|
677
|
+
throw new Error("frozenMaxAgents is invalid");
|
|
678
|
+
}
|
|
679
|
+
return normalizeMaxAgents(raw.frozenMaxAgents);
|
|
680
|
+
})();
|
|
681
|
+
const frozenConcurrency = (() => {
|
|
682
|
+
if (!Object.hasOwn(raw, "frozenConcurrency")) return undefined;
|
|
683
|
+
if (
|
|
684
|
+
typeof raw.frozenConcurrency !== "number" ||
|
|
685
|
+
!Number.isFinite(raw.frozenConcurrency) ||
|
|
686
|
+
raw.frozenConcurrency < 1
|
|
687
|
+
) {
|
|
688
|
+
throw new Error("frozenConcurrency is invalid");
|
|
689
|
+
}
|
|
690
|
+
return normalizeConcurrency(raw.frozenConcurrency);
|
|
691
|
+
})();
|
|
692
|
+
const frozenAgentRetries = (() => {
|
|
693
|
+
if (!Object.hasOwn(raw, "frozenAgentRetries")) return undefined;
|
|
694
|
+
if (
|
|
695
|
+
typeof raw.frozenAgentRetries !== "number" ||
|
|
696
|
+
!Number.isFinite(raw.frozenAgentRetries) ||
|
|
697
|
+
raw.frozenAgentRetries < 0
|
|
698
|
+
) {
|
|
699
|
+
throw new Error("frozenAgentRetries is invalid");
|
|
700
|
+
}
|
|
701
|
+
return normalizeAgentRetries(raw.frozenAgentRetries);
|
|
702
|
+
})();
|
|
703
|
+
const frozenTokenBudget = (() => {
|
|
704
|
+
if (!Object.hasOwn(raw, "frozenTokenBudget")) return undefined;
|
|
705
|
+
if (raw.frozenTokenBudget === null) return null;
|
|
706
|
+
if (
|
|
707
|
+
typeof raw.frozenTokenBudget !== "number" ||
|
|
708
|
+
!Number.isFinite(raw.frozenTokenBudget) ||
|
|
709
|
+
raw.frozenTokenBudget < 1
|
|
710
|
+
) {
|
|
711
|
+
throw new Error("frozenTokenBudget is invalid");
|
|
712
|
+
}
|
|
713
|
+
return normalizeTokenBudget(raw.frozenTokenBudget);
|
|
714
|
+
})();
|
|
715
|
+
const frozenAgentTimeoutMs = (() => {
|
|
716
|
+
if (!Object.hasOwn(raw, "frozenAgentTimeoutMs")) return undefined;
|
|
717
|
+
if (raw.frozenAgentTimeoutMs === null) return null;
|
|
718
|
+
if (
|
|
719
|
+
typeof raw.frozenAgentTimeoutMs !== "number" ||
|
|
720
|
+
!Number.isFinite(raw.frozenAgentTimeoutMs) ||
|
|
721
|
+
raw.frozenAgentTimeoutMs < 1
|
|
722
|
+
) {
|
|
723
|
+
throw new Error("frozenAgentTimeoutMs is invalid");
|
|
724
|
+
}
|
|
725
|
+
return normalizeAgentTimeout(raw.frozenAgentTimeoutMs);
|
|
726
|
+
})();
|
|
727
|
+
const frozenExcludeTools = (() => {
|
|
728
|
+
if (!Object.hasOwn(raw, "frozenExcludeTools")) return undefined;
|
|
729
|
+
if (!Array.isArray(raw.frozenExcludeTools) || !raw.frozenExcludeTools.every((v) => typeof v === "string")) {
|
|
730
|
+
throw new Error("frozenExcludeTools is invalid");
|
|
731
|
+
}
|
|
732
|
+
return [...raw.frozenExcludeTools] as string[];
|
|
733
|
+
})();
|
|
721
734
|
return {
|
|
722
735
|
kind: "run_created",
|
|
723
736
|
schemaVersion: raw.schemaVersion as JournalSchemaVersion,
|
|
@@ -726,7 +739,7 @@ function parseEvent(
|
|
|
726
739
|
scriptHash,
|
|
727
740
|
meta,
|
|
728
741
|
...(args === undefined ? {} : { args }),
|
|
729
|
-
|
|
742
|
+
frozenArgsPresent,
|
|
730
743
|
...(toolset ? { toolset } : {}),
|
|
731
744
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
732
745
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -742,21 +755,6 @@ function parseEvent(
|
|
|
742
755
|
|
|
743
756
|
const run = runs.get(runId);
|
|
744
757
|
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
758
|
if (raw.kind === "run_revision") {
|
|
761
759
|
const revision = raw.revision;
|
|
762
760
|
if (typeof revision !== "number" || !Number.isInteger(revision) || revision <= (run.revision ?? 0))
|
|
@@ -819,7 +817,10 @@ function parseEvent(
|
|
|
819
817
|
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
|
|
820
818
|
if (raw.owner !== undefined || raw.attemptId !== undefined)
|
|
821
819
|
throw new Error("workflow result cannot carry a managed attempt");
|
|
822
|
-
const result =
|
|
820
|
+
const result = mergeCallResultTierIdentity(
|
|
821
|
+
priorCallTierIdentity(run, nodeId),
|
|
822
|
+
parseResult(raw.result, "workflow result"),
|
|
823
|
+
);
|
|
823
824
|
if (result.status !== "completed") throw new Error("workflow result must be completed");
|
|
824
825
|
if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
|
|
825
826
|
const generation = raw.generation;
|
|
@@ -895,6 +896,7 @@ function parseEvent(
|
|
|
895
896
|
if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
|
|
896
897
|
throw new Error("call attempt does not identify its next generation");
|
|
897
898
|
}
|
|
899
|
+
const tierIdentity = parseCallTierIdentity(raw, "call attempt");
|
|
898
900
|
return {
|
|
899
901
|
kind: "call_attempt",
|
|
900
902
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
@@ -902,6 +904,7 @@ function parseEvent(
|
|
|
902
904
|
nodeId,
|
|
903
905
|
attemptId,
|
|
904
906
|
generation,
|
|
907
|
+
...tierIdentity,
|
|
905
908
|
timestamp,
|
|
906
909
|
...(owner ? { owner } : {}),
|
|
907
910
|
};
|
|
@@ -910,12 +913,14 @@ function parseEvent(
|
|
|
910
913
|
if (raw.kind === "call_transition") {
|
|
911
914
|
if (!CALL_STATUS_VALUES.has(raw.status as CallStatus)) throw new Error("call status is unknown");
|
|
912
915
|
const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
|
|
916
|
+
const tierIdentity = parseCallTierIdentity(raw, "call transition");
|
|
913
917
|
return {
|
|
914
918
|
kind: "call_transition",
|
|
915
919
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
916
920
|
runId,
|
|
917
921
|
nodeId,
|
|
918
922
|
status: raw.status as CallStatus,
|
|
923
|
+
...tierIdentity,
|
|
919
924
|
timestamp,
|
|
920
925
|
...(agentId ? { agentId } : {}),
|
|
921
926
|
...(attemptId ? { attemptId } : {}),
|
|
@@ -930,12 +935,14 @@ function parseEvent(
|
|
|
930
935
|
if (result.attemptId && attemptId && result.attemptId !== attemptId) {
|
|
931
936
|
throw new StaleJournalGenerationError("call result payload references a superseded attempt");
|
|
932
937
|
}
|
|
938
|
+
const normalizedResult = result.attemptId === undefined && attemptId ? { ...result, attemptId } : result;
|
|
939
|
+
const resultWithTierIdentity = mergeCallResultTierIdentity(priorCallTierIdentity(run, nodeId), normalizedResult);
|
|
933
940
|
return {
|
|
934
941
|
kind: "call_result",
|
|
935
942
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
936
943
|
runId,
|
|
937
944
|
nodeId,
|
|
938
|
-
result:
|
|
945
|
+
result: resultWithTierIdentity,
|
|
939
946
|
...(raw.callHash === undefined ? {} : { callHash: boundedString(raw.callHash, "callHash", 128) }),
|
|
940
947
|
...(raw.storeDelta === undefined
|
|
941
948
|
? {}
|
|
@@ -969,7 +976,8 @@ export function replayJournal(
|
|
|
969
976
|
const runs = new Map<string, ScriptRun>();
|
|
970
977
|
const quarantined = new Set<string>();
|
|
971
978
|
const removed = new Set<string>();
|
|
972
|
-
|
|
979
|
+
/** Parsed creation facts are retained separately from mutable run state for duplicate checks. */
|
|
980
|
+
const creationFingerprints = new Map<string, string>();
|
|
973
981
|
/** Recovery identities are scoped to one replayed run and bounded explicitly. */
|
|
974
982
|
const seenRecoveries = new Map<string, Map<string, string>>();
|
|
975
983
|
let reported = false;
|
|
@@ -984,18 +992,28 @@ export function replayJournal(
|
|
|
984
992
|
const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
|
|
985
993
|
if (rawRunId && quarantined.has(rawRunId)) continue;
|
|
986
994
|
if (rawRunId && removed.has(rawRunId)) continue;
|
|
995
|
+
// Set inside the guarded region, delivered outside it. An observer that
|
|
996
|
+
// throws is reporting that the accepted facts contradict each other, which
|
|
997
|
+
// is exactly the condition the caller needs to see — running it inside the
|
|
998
|
+
// `try` would quarantine the run and swallow the diagnostic, turning a real
|
|
999
|
+
// conflict into a silently missing cache entry. `finally` also delivers on
|
|
1000
|
+
// the `continue` paths, which a trailing statement would skip.
|
|
1001
|
+
let accepted: JournalEvent | undefined;
|
|
987
1002
|
try {
|
|
988
|
-
if (
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1003
|
+
if (
|
|
1004
|
+
isRecord(raw) &&
|
|
1005
|
+
raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
|
|
1006
|
+
raw.kind === "run_created" &&
|
|
1007
|
+
rawRunId &&
|
|
1008
|
+
runs.has(rawRunId)
|
|
1009
|
+
) {
|
|
1010
|
+
// Parse against an empty run map so every creation field participates in
|
|
1011
|
+
// the comparison, including frozen limits and attempt seeds. Comparing
|
|
1012
|
+
// against mutable run state would make a harmless retry conflict after
|
|
1013
|
+
// later call/recovery events have changed that state.
|
|
1014
|
+
const candidate = parseEvent(raw, new Map());
|
|
1015
|
+
const candidateFingerprint = JSON.stringify(candidate);
|
|
1016
|
+
if (creationFingerprints.get(rawRunId) === candidateFingerprint) continue;
|
|
999
1017
|
throw new Error("conflicting duplicate workflow run_created event");
|
|
1000
1018
|
}
|
|
1001
1019
|
if (
|
|
@@ -1008,7 +1026,12 @@ export function replayJournal(
|
|
|
1008
1026
|
throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
|
|
1009
1027
|
}
|
|
1010
1028
|
}
|
|
1011
|
-
if (
|
|
1029
|
+
if (
|
|
1030
|
+
isRecord(raw) &&
|
|
1031
|
+
raw.schemaVersion === JOURNAL_SCHEMA_VERSION &&
|
|
1032
|
+
(raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
|
|
1033
|
+
rawRunId
|
|
1034
|
+
) {
|
|
1012
1035
|
const run = runs.get(rawRunId);
|
|
1013
1036
|
const seen = seenRecoveries.get(rawRunId);
|
|
1014
1037
|
if (run && seen) {
|
|
@@ -1027,11 +1050,13 @@ export function replayJournal(
|
|
|
1027
1050
|
}
|
|
1028
1051
|
}
|
|
1029
1052
|
|
|
1030
|
-
const event = parseEvent(raw, runs
|
|
1053
|
+
const event = parseEvent(raw, runs);
|
|
1031
1054
|
if (quarantined.has(event.runId)) continue;
|
|
1032
1055
|
if (event.kind === "run_created") {
|
|
1033
1056
|
applyJournalEvent(runs, event);
|
|
1057
|
+
creationFingerprints.set(event.runId, JSON.stringify(event));
|
|
1034
1058
|
seenRecoveries.set(event.runId, new Map());
|
|
1059
|
+
accepted = event;
|
|
1035
1060
|
continue;
|
|
1036
1061
|
}
|
|
1037
1062
|
if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
|
|
@@ -1050,23 +1075,25 @@ export function replayJournal(
|
|
|
1050
1075
|
applyJournalEvent(runs, event);
|
|
1051
1076
|
seen.set(event.recoveryId, recoverySemanticFingerprint(event));
|
|
1052
1077
|
seenRecoveries.set(event.runId, seen);
|
|
1078
|
+
accepted = event;
|
|
1053
1079
|
continue;
|
|
1054
1080
|
}
|
|
1055
1081
|
applyJournalEvent(runs, event);
|
|
1056
|
-
|
|
1082
|
+
accepted = event;
|
|
1057
1083
|
if (event.kind === "run_removed") {
|
|
1058
1084
|
removed.add(event.runId);
|
|
1059
|
-
legacyArgsBackfilled.delete(event.runId);
|
|
1060
1085
|
}
|
|
1061
1086
|
} catch (error: unknown) {
|
|
1062
1087
|
if (error instanceof StaleJournalGenerationError) continue;
|
|
1063
1088
|
if (rawRunId) {
|
|
1064
1089
|
quarantined.add(rawRunId);
|
|
1065
1090
|
runs.delete(rawRunId);
|
|
1091
|
+
creationFingerprints.delete(rawRunId);
|
|
1066
1092
|
seenRecoveries.delete(rawRunId);
|
|
1067
|
-
legacyArgsBackfilled.delete(rawRunId);
|
|
1068
1093
|
}
|
|
1069
1094
|
report(error instanceof Error ? error.message : String(error));
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (accepted) options.onAccepted?.(accepted);
|
|
1070
1097
|
}
|
|
1071
1098
|
}
|
|
1072
1099
|
for (const [runId, run] of runs) {
|
|
@@ -1075,6 +1102,7 @@ export function replayJournal(
|
|
|
1075
1102
|
} catch (error: unknown) {
|
|
1076
1103
|
runs.delete(runId);
|
|
1077
1104
|
quarantined.add(runId);
|
|
1105
|
+
creationFingerprints.delete(runId);
|
|
1078
1106
|
seenRecoveries.delete(runId);
|
|
1079
1107
|
report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1080
1108
|
}
|
|
@@ -1088,15 +1116,19 @@ function applyRecoveryRotation(run: ScriptRun, rotation: RecoveryRotation): void
|
|
|
1088
1116
|
run.attemptIds[rotation.nodeId] = rotation.attemptId;
|
|
1089
1117
|
delete run.agentIds[rotation.nodeId];
|
|
1090
1118
|
delete run.callResults[rotation.nodeId];
|
|
1119
|
+
const rotationTier = rotation.tier ?? run.callTiers[rotation.nodeId]?.tier;
|
|
1120
|
+
if (rotationTier !== undefined) run.callTiers[rotation.nodeId] = { tier: rotationTier };
|
|
1091
1121
|
run.compactions[rotation.nodeId] = 0;
|
|
1092
1122
|
run.callStatus[rotation.nodeId] = "running";
|
|
1093
1123
|
}
|
|
1094
1124
|
|
|
1095
1125
|
function applyRecoveryTerminalResult(run: ScriptRun, terminal: RecoveryTerminalResult): void {
|
|
1096
1126
|
run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
|
|
1097
|
-
run
|
|
1098
|
-
run.
|
|
1099
|
-
|
|
1127
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, terminal.nodeId), terminal.result);
|
|
1128
|
+
run.callResults[terminal.nodeId] = result;
|
|
1129
|
+
run.callTiers[terminal.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1130
|
+
run.callStatus[terminal.nodeId] = result.status;
|
|
1131
|
+
if (result.agentId) run.agentIds[terminal.nodeId] = result.agentId;
|
|
1100
1132
|
}
|
|
1101
1133
|
|
|
1102
1134
|
export function applyRecoveryTerminalResults(run: ScriptRun, terminalResults: readonly RecoveryTerminalResult[]): void {
|
|
@@ -1145,7 +1177,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1145
1177
|
scriptHash: event.scriptHash,
|
|
1146
1178
|
meta: event.meta,
|
|
1147
1179
|
...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
|
|
1148
|
-
|
|
1180
|
+
frozenArgsPresent: event.frozenArgsPresent,
|
|
1149
1181
|
revision: 0,
|
|
1150
1182
|
...(event.toolset ? { toolset: event.toolset } : {}),
|
|
1151
1183
|
...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
|
|
@@ -1161,6 +1193,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1161
1193
|
attemptIds,
|
|
1162
1194
|
attemptTracking: event.attemptIds !== undefined,
|
|
1163
1195
|
callResults: {},
|
|
1196
|
+
callTiers: {},
|
|
1164
1197
|
workflowResultGenerations: {},
|
|
1165
1198
|
compactions: {},
|
|
1166
1199
|
startedAt: event.timestamp,
|
|
@@ -1177,9 +1210,6 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1177
1210
|
}
|
|
1178
1211
|
run.updatedAt = event.timestamp;
|
|
1179
1212
|
switch (event.kind) {
|
|
1180
|
-
case "run_args":
|
|
1181
|
-
run.args = structuredClone(event.args);
|
|
1182
|
-
break;
|
|
1183
1213
|
case "run_revision":
|
|
1184
1214
|
run.revision = event.revision;
|
|
1185
1215
|
run.script = event.script;
|
|
@@ -1205,6 +1235,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1205
1235
|
run.attemptIds[event.nodeId] = event.attemptId;
|
|
1206
1236
|
delete run.agentIds[event.nodeId];
|
|
1207
1237
|
delete run.callResults[event.nodeId];
|
|
1238
|
+
if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
|
|
1208
1239
|
run.callStatus[event.nodeId] = "running";
|
|
1209
1240
|
run.compactions[event.nodeId] = 0;
|
|
1210
1241
|
break;
|
|
@@ -1212,30 +1243,42 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1212
1243
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1213
1244
|
throw new Error("call transition references a superseded attempt");
|
|
1214
1245
|
}
|
|
1215
|
-
run.
|
|
1246
|
+
if (event.tier !== undefined) run.callTiers[event.nodeId] = { tier: event.tier };
|
|
1247
|
+
// Identity-only transitions use status "running". A delayed retry must
|
|
1248
|
+
// not regress a terminal call result that was already replayed.
|
|
1249
|
+
if (
|
|
1250
|
+
event.status !== "running" ||
|
|
1251
|
+
run.callStatus[event.nodeId] === undefined ||
|
|
1252
|
+
run.callStatus[event.nodeId] === "running"
|
|
1253
|
+
) {
|
|
1254
|
+
run.callStatus[event.nodeId] = event.status;
|
|
1255
|
+
}
|
|
1216
1256
|
if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
|
|
1217
1257
|
break;
|
|
1218
|
-
case "workflow_result":
|
|
1258
|
+
case "workflow_result": {
|
|
1219
1259
|
run.workflowResultGenerations[event.nodeId] = event.generation;
|
|
1220
|
-
run
|
|
1221
|
-
run.
|
|
1222
|
-
run.
|
|
1260
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
|
|
1261
|
+
run.callResults[event.nodeId] = result;
|
|
1262
|
+
run.callTiers[event.nodeId] = result.tier === undefined ? {} : { tier: result.tier };
|
|
1263
|
+
run.callStatus[event.nodeId] = result.status;
|
|
1264
|
+
run.compactions[event.nodeId] = result.compactionCount;
|
|
1223
1265
|
break;
|
|
1224
|
-
|
|
1266
|
+
}
|
|
1267
|
+
case "call_result": {
|
|
1225
1268
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1226
1269
|
throw new Error("call result references a superseded attempt");
|
|
1227
1270
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(event.result)
|
|
1231
|
-
) {
|
|
1271
|
+
const result = mergeCallResultTierIdentity(priorCallTierIdentity(run, event.nodeId), event.result);
|
|
1272
|
+
if (run.callResults[event.nodeId] && JSON.stringify(run.callResults[event.nodeId]) !== JSON.stringify(result)) {
|
|
1232
1273
|
throw new Error("contradictory terminal call result");
|
|
1233
1274
|
}
|
|
1234
|
-
run.callResults[event.nodeId] =
|
|
1235
|
-
run.
|
|
1236
|
-
run.
|
|
1237
|
-
|
|
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] = Math.max(run.compactions[event.nodeId] ?? 0, result.compactionCount);
|
|
1279
|
+
if (result.agentId) run.agentIds[event.nodeId] = result.agentId;
|
|
1238
1280
|
break;
|
|
1281
|
+
}
|
|
1239
1282
|
}
|
|
1240
1283
|
}
|
|
1241
1284
|
|
|
@@ -1310,9 +1353,9 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1310
1353
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1311
1354
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1312
1355
|
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1313
|
-
* event that carries a `callHash` is a replayable fact
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1356
|
+
* event that carries a `callHash` is a replayable fact. Entries from another
|
|
1357
|
+
* schema or without a hash are not replayable; they are ignored here even when
|
|
1358
|
+
* `replayJournal` retains them as non-replayable lifecycle history.
|
|
1316
1359
|
*/
|
|
1317
1360
|
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1318
1361
|
string,
|
|
@@ -1321,88 +1364,70 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1321
1364
|
runId?: string;
|
|
1322
1365
|
hash: string;
|
|
1323
1366
|
result: unknown;
|
|
1367
|
+
tier?: string;
|
|
1324
1368
|
storeDelta?: Record<string, unknown>;
|
|
1325
1369
|
generation?: number;
|
|
1326
1370
|
agentCount?: number;
|
|
1327
1371
|
}
|
|
1328
1372
|
> {
|
|
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");
|
|
1373
|
+
type ResumeEntry = {
|
|
1374
|
+
index: number;
|
|
1375
|
+
runId?: string;
|
|
1376
|
+
hash: string;
|
|
1377
|
+
result: unknown;
|
|
1378
|
+
tier?: string;
|
|
1379
|
+
storeDelta?: Record<string, unknown>;
|
|
1380
|
+
generation?: number;
|
|
1381
|
+
agentCount?: number;
|
|
1382
|
+
};
|
|
1383
|
+
const journal = new Map<string, ResumeEntry>();
|
|
1384
|
+
const journalRunIds = new Map<string, string>();
|
|
1385
|
+
|
|
1386
|
+
// Reuse the authoritative replay validator so stale call results from an
|
|
1387
|
+
// older attempt, terminal-after-lifecycle facts, and quarantined runs never
|
|
1388
|
+
// enter the cache merely because they appear later in the session branch.
|
|
1389
|
+
const replayedRuns = replayJournal(entries, {
|
|
1390
|
+
onAccepted: (event) => {
|
|
1391
|
+
if ((event.kind !== "call_result" && event.kind !== "workflow_result") || event.callHash === undefined) return;
|
|
1392
|
+
if (event.result.status !== "completed" || !Object.hasOwn(event.result, "result")) return;
|
|
1393
|
+
|
|
1394
|
+
const candidate: ResumeEntry = {
|
|
1395
|
+
index: Number(event.nodeId),
|
|
1396
|
+
runId: event.runId,
|
|
1397
|
+
hash: event.callHash,
|
|
1398
|
+
result: event.result.result,
|
|
1399
|
+
...(event.result.tier === undefined ? {} : { tier: event.result.tier }),
|
|
1400
|
+
...(event.kind === "workflow_result" ? { generation: event.generation } : {}),
|
|
1401
|
+
...(event.kind === "workflow_result" && event.agentCount !== undefined ? { agentCount: event.agentCount } : {}),
|
|
1402
|
+
...(event.storeDelta !== undefined && Object.keys(event.storeDelta).length > 0
|
|
1403
|
+
? { storeDelta: event.storeDelta }
|
|
1404
|
+
: {}),
|
|
1405
|
+
};
|
|
1406
|
+
const key = `${event.runId}:${event.nodeId}`;
|
|
1407
|
+
const existing = journal.get(key);
|
|
1408
|
+
if (candidate.generation !== undefined && existing?.generation !== undefined) {
|
|
1409
|
+
if (candidate.generation < existing.generation) return;
|
|
1410
|
+
// Same generation, same content is an at-least-once retry. Same
|
|
1411
|
+
// generation, different content is a contradiction `replayJournal` has
|
|
1412
|
+
// already rejected before accepting the second fact — reaching here
|
|
1413
|
+
// would mean the two validators disagree, so surface it rather than
|
|
1414
|
+
// quietly keeping whichever arrived first.
|
|
1415
|
+
if (candidate.generation === existing.generation) {
|
|
1416
|
+
if (JSON.stringify(existing) !== JSON.stringify(candidate))
|
|
1417
|
+
throw new Error("contradictory nested workflow result");
|
|
1418
|
+
return;
|
|
1401
1419
|
}
|
|
1402
|
-
continue;
|
|
1403
1420
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1421
|
+
journal.set(key, candidate);
|
|
1422
|
+
journalRunIds.set(key, event.runId);
|
|
1423
|
+
},
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// replayJournal may quarantine a run after an accepted fact (for example,
|
|
1427
|
+
// when a later contradictory event invalidates the aggregate). Do not retain
|
|
1428
|
+
// cache entries belonging to that run.
|
|
1429
|
+
for (const [key, runId] of journalRunIds) {
|
|
1430
|
+
if (!replayedRuns.has(runId)) journal.delete(key);
|
|
1406
1431
|
}
|
|
1407
1432
|
return journal;
|
|
1408
1433
|
}
|