Skip to content
Merged
25 changes: 16 additions & 9 deletions docs/architecture/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,22 @@ that can reach the run:
- VS Code: a sidebar / status-bar prompt and a WebviewPanel card.
- CLI: a terminal prompt (`relavium gate`).

When a decision arrives the engine reloads state, emits `human_gate:resumed`, and
the run continues. Because the gate state is checkpointed, resolving it is
idempotent across a reconnect — re-delivering the same decision does not advance
the run twice. **Parallel branches may each reach a gate, so multiple gates can be pending at
once** — each resolves independently with its own timeout (a `run:paused` aggregate reflects that
≥1 gate is pending). A gate may carry a timeout with an `on_timeout` policy (`reject` /
`approve`; `escalate` is **reserved** in v1.0 — authored in YAML as `timeout_action`; see
[workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md#human_gate-node)); this
prevents a forgotten gate from blocking a run forever.
When a decision arrives — `approved`, `rejected`, or `input_provided` — the engine emits
`human_gate:resumed` and the run **continues**: the gate node completes with the decision as its
output, so the author routes on it with a downstream `condition` (a `rejected` decision does not
itself fail the run). Because the gate state is checkpointed, resolving it is idempotent across a
reconnect — re-delivering the same decision does not advance the run twice. **Parallel branches may
each reach a gate, so multiple gates can be pending at once** — each resolves independently with its
own timeout (a `run:paused` aggregate reflects that ≥1 gate is pending). A gate may carry a timeout
with an `on_timeout` policy (`reject` / `approve`; `escalate` is **reserved** in v1.0 — authored in
YAML as `timeout_action`; see
[workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md#human_gate-node)), armed as a
one-shot timer from the injected clock when the gate parks. The two timeout outcomes differ from a
human decision: `approve` **auto-resolves** the gate as approved (`decidedBy: 'timeout'`, the run
continues); `reject` **fails** the run with `run_timeout` (the `AwaitingGate → Failed` edge above) —
this is what stops a forgotten gate from blocking a run forever. A decision that arrives first
disarms the timer.

The gate event/decision shapes are part of the
[SSE event schema](../reference/contracts/sse-event-schema.md) and the
[IPC contract](../reference/contracts/ipc-contract.md).
Expand Down
48 changes: 41 additions & 7 deletions docs/architecture/shared-core-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,47 @@ This is what enables:
`runId + nodeId + retryCount`, so a retry never double-applies side effects.

In Phase 1 there is **no separate checkpoint table**: the checkpoint is **reconstructed** by a
`Checkpointer` (`load(runId) → CheckpointState`) from the per-node `step_executions` rows
(`status` / `attempt_number` / `output_json` / `error_json`) and the ordered, replayable `run_events`
log, with the orchestrator's message history in `messages` (schema in
[../reference/desktop/database-schema.md](../reference/desktop/database-schema.md)).
`CheckpointState = { runStatus, nodeStates, completedNodeIds, pendingNodeIds, orchestratorMessages? }`
is **derived**, never a stored blob. The same derivation is what the Phase-2 cloud layer uses for
durable execution — see [cloud-phase-2.md](cloud-phase-2.md).
`Checkpointer` (`load(runId) → CheckpointState`) by folding the ordered, replayable `run_events` log
alone — each node's output/error rides its `node:completed` / `node:failed` event, so the stream is a
sufficient source. The persistence layer *also* denormalizes per-node state into `step_executions` and an
orchestrator's history into `messages` (schema in
[../reference/desktop/database-schema.md](../reference/desktop/database-schema.md)) for the run-trace UI
and fast querying — the same per-node truth, not an extra input the fold requires.
`CheckpointState` is **derived**, never a stored blob: a pure fold over the ordered event stream
(`reconstructCheckpointState(events)`) captures run status, the surrogate `workflowId`, per-node
settled/paused states (with a `condition`'s selected branch from `node:completed.selected` and dimmed
branches from `node:skipped`), pending and already-resolved gate ids, the last `sequenceNumber`, and the
running token/cost tallies. The exact field set is the `CheckpointState` interface in
[`packages/core/src/engine/checkpoint.ts`](../../packages/core/src/engine/checkpoint.ts) — the one
authoritative shape; this section does not restate it. The same derivation is what the Phase-2 cloud
layer uses for durable execution — see [cloud-phase-2.md](cloud-phase-2.md).

**Reconstruction is total and deterministic** (same events → same state — the basis of idempotent
resume). A node that emitted `node:started` but no terminal event (it was running when the process
died) is simply **absent** from `nodeStates`, so the rehydrating engine seeds it `pending` and re-runs
it — bounded by the `runId + nodeId + retryCount` idempotency key, never by silently skipping it. What is
**not** in the checkpoint: the eager-once resolved `context` (`ctx.*`) is **re-resolved at run start**,
not reconstructed — and if a later change makes it part of a transported checkpoint it MUST cross that
boundary via `structuredClone`, never `JSON.stringify`→`parse` (which would re-materialise a `__proto__`
key as a real setter; the standing note lives at
[`interpolation/resolve.ts`](../../packages/core/src/interpolation/resolve.ts)).

A run suspended at a gate resumes in **two ways**: in the same process, `engine.resume(runId, gateId,
decision)`; across a restart, `engine.resumeFromCheckpoint({ runId, workflow, gateId, decision })`
rehydrates a fresh `RunExecution` from the reconstructed state (seeding node states, pending gates,
tallies, and the `sequenceNumber` so post-resume events continue gap-free — no `run:started` is
re-emitted) and returns a `RunHandle` for the rest of the run. An **identity guard** refuses a resume
whose workflow is not the one the run started on: the Phase-1 in-memory reference compares the surrogate
`workflowId` reconstructed from `run:started` (a different workflow → a typed `workflow_mismatch`). The
stronger guard that also catches a *same-slug, edited-content* workflow rides on the frozen
`runs.workflow_definition_snapshot` column ([../reference/desktop/database-schema.md](../reference/desktop/database-schema.md))
— a Phase-2 persistence concern wired with the real `RunStore`, not the event-derived in-memory state. **Idempotent re-delivery** never advances a run twice: re-delivering a decision to an
already-terminal run is a no-op (a closed handle, nothing re-emitted or re-persisted); re-delivering an
already-resolved gate on a still-running run drives the remaining work without re-applying the decision.
This holds within a process, and across processes once the prior process's `human_gate:resumed` is
persisted; the residual concurrent window (two processes loading the *same* still-pending gate before
either persists) is closed by a Phase-2 store-level uniqueness constraint on `human_gate:resumed` per
gate, not by the in-memory reference.

## Retry and fallback

Expand Down
14 changes: 12 additions & 2 deletions docs/reference/contracts/sse-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type RunEvent =
| CostUpdatedEvent
| NodeCompletedEvent
| NodeFailedEvent
| NodeSkippedEvent
| HumanGatePausedEvent
| HumanGateResumedEvent
| RunCompletedEvent
Expand All @@ -72,9 +73,10 @@ export type RunEvent =
| `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI), `attemptNumber?` |
| `agent:file_patch_proposed` | An agent proposed a file change (**gated — no write until the user accepts**; e.g. the VS Code inline-diff review). | `nodeId`, `patches: [{ uri, unifiedDiff }]` (≥1 — an empty proposal is meaningless), `attemptNumber?` |
| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)), `attemptNumber?` (1-based retry attempt this cost belongs to, so per-attempt cost is reconstructable) |
| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `attemptNumber?` |
| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model?}` (`model` only for LLM nodes), `durationMs`, `selected?` (a `condition`'s chosen target ids — the authoritative branch record checkpoint/resume restores from, 1.R; **may be an empty array** when the condition routes to no branch, dimming all downstream), `attemptNumber?` |
| `node:failed` | A node failed. | `nodeId`, `error: {code, message, retryable, correlationId?}` (`code` is an [`ErrorCode`](#error-code-taxonomy); `correlationId` is a secret-free id joined to the internal log — ADR-0036) |
| `human_gate:paused` | Execution suspended at a human gate. | `nodeId`, `gateId`, `gateType: 'approval' \| 'input' \| 'review'`, `message`, `assignee?`, `timeoutMs?`, `expiresAt?` |
| `node:skipped` | A node was skip-propagated (never ran). | `nodeId`, `reason: 'branch_not_taken' \| 'upstream_unreachable'` (`branch_not_taken` = a `condition` routed away from it; `upstream_unreachable` = every in-edge is dead because an upstream was skipped/failed). Emitted so the event log is a **complete, replayable** record — checkpoint/resume reconstructs a skipped vertex from it ([run-plan.md](../shared-core/run-plan.md)) and a surface can render the dimmed path instead of the node silently vanishing. |
| `human_gate:paused` | Execution suspended at a human gate. | `nodeId`, `gateId`, `gateType: 'approval' \| 'input' \| 'review'`, `message`, `assignee?`, `timeoutMs?`, `timeoutAction?: 'approve' \| 'reject'` (on-timeout policy, present only with `timeoutMs`), `expiresAt?` |
| `human_gate:resumed` | A gate decision was applied; execution continues. | `nodeId`, `decision: 'approved' \| 'rejected' \| 'input_provided'`, `decidedBy`, `payload?` |
| `run:paused` | The run is suspended with **≥1 gate pending** — the multi-gate aggregate that backs the pending-gate queue (parallel branches may each reach a gate). | `pendingGateCount`, `gateIds[]` |
| `run:completed` | The run finished. | `outputs` (a record **keyed by each terminal `output` vertex's node id**, the value being that vertex's captured output — see [run-plan.md §output capture](../shared-core/run-plan.md)), `totalTokensUsed`, `totalCostMicrocents` (integer micro-cents closing total for the whole run), `durationMs` |
Expand Down Expand Up @@ -117,9 +119,16 @@ export interface NodeCompletedEvent extends BaseEvent {
// no model — so `model` is optional.
tokensUsed: { input: number; output: number; model?: string };
durationMs: number;
selected?: string[]; // a `condition` node only: the immediate target ids it routed to (the live branches); MAY be empty when it routes to no branch (all downstream skip-propagated). The authoritative record checkpoint/resume restores `selectedTargets` from (1.R).
attemptNumber?: number; // 1-based retry attempt this completion belongs to (matches cost:updated)
}

export interface NodeSkippedEvent extends BaseEvent {
type: 'node:skipped';
nodeId: string;
reason: 'branch_not_taken' | 'upstream_unreachable';
}

export interface HumanGatePausedEvent extends BaseEvent {
type: 'human_gate:paused';
nodeId: string;
Expand All @@ -128,6 +137,7 @@ export interface HumanGatePausedEvent extends BaseEvent {
message: string;
assignee?: string;
timeoutMs?: number;
timeoutAction?: 'approve' | 'reject'; // on-timeout policy (present only with timeoutMs); lets a surface show how the gate auto-resolves and a Phase-2 crash-resume re-arm the timer from the log
expiresAt?: string;
}
```
Expand Down
240 changes: 240 additions & 0 deletions packages/core/src/engine/checkpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { RunEvent } from '@relavium/shared';
import { describe, expect, it } from 'vitest';

import { reconstructCheckpointState } from './checkpoint.js';
import { InMemoryRunStore, createInMemoryCheckpointer } from './execution-host.js';

const TS = '2026-01-01T00:00:00.000Z';
const base = (sequenceNumber: number) => ({ runId: 'r1', sequenceNumber, timestamp: TS });

const started: RunEvent = {
type: 'run:started',
...base(0),
workflowId: '00000000-0000-4000-8000-000000000001',
inputs: {},
executionMode: 'local',
};
const completed = (seq: number, nodeId: string, output: unknown): RunEvent => ({
type: 'node:completed',
...base(seq),
nodeId,
output,
tokensUsed: { input: 0, output: 0 },
durationMs: 1,
});

describe('reconstructCheckpointState', () => {
it('returns undefined for a run with no run:started', () => {
expect(reconstructCheckpointState([completed(1, 'a', 1)])).toBeUndefined();
});

it('reconstructs a completed run (status + nodeStates + lastSequenceNumber)', () => {
const state = reconstructCheckpointState([
started,
completed(1, 'a', { v: 1 }),
{
type: 'run:completed',
...base(2),
outputs: {},
totalTokensUsed: { input: 0, output: 0 },
totalCostMicrocents: 0,
durationMs: 1,
},
]);
expect(state?.runStatus).toBe('completed');
expect(state?.workflowId).toBe('00000000-0000-4000-8000-000000000001'); // captured from run:started
expect(state?.startedAtMs).toBe(Date.parse(TS)); // original start epoch, so resumed durationMs is total
expect(state?.nodeStates.get('a')).toEqual({ status: 'completed', output: { v: 1 } });
expect(state?.completedNodeIds).toEqual(['a']);
expect(state?.lastSequenceNumber).toBe(2);
});

it('OMITS a node that started but never finished — so the rehydrating engine re-runs it (trap b)', () => {
const state = reconstructCheckpointState([
started,
completed(1, 'a', 'A'),
{ type: 'node:started', ...base(2), nodeId: 'b', nodeType: 'agent' }, // crashed mid-flight
]);
expect(state?.runStatus).toBe('running');
expect(state?.nodeStates.has('a')).toBe(true);
expect(state?.nodeStates.has('b')).toBe(false); // absent → engine seeds 'pending' → re-runs
});

it('restores a condition selectedTargets + the dimmed branch as skipped (resume routes correctly)', () => {
const state = reconstructCheckpointState([
started,
{
type: 'node:completed',
...base(1),
nodeId: 'gate',
output: { decision: true },
tokensUsed: { input: 0, output: 0 },
durationMs: 1,
selected: ['hi'],
},
{ type: 'node:skipped', ...base(2), nodeId: 'lo', reason: 'branch_not_taken' },
]);
expect(state?.nodeStates.get('gate')).toEqual({
status: 'completed',
output: { decision: true },
selectedTargets: ['hi'],
});
expect(state?.nodeStates.get('lo')).toEqual({ status: 'skipped' });
});

it('reconstructs a gate-parked run (paused status + pendingGates + paused node)', () => {
const state = reconstructCheckpointState([
started,
{
type: 'human_gate:paused',
...base(1),
nodeId: 'gate',
gateId: 'g1',
gateType: 'approval',
message: 'ok?',
},
{ type: 'run:paused', ...base(2), pendingGateCount: 1, gateIds: ['g1'] },
]);
expect(state?.runStatus).toBe('paused');
expect(state?.nodeStates.get('gate')).toEqual({ status: 'paused' });
expect(state?.pendingGates).toEqual([{ gateId: 'g1', nodeId: 'gate' }]);
});

it('a resumed gate clears the pending gate + records the decision as the node output', () => {
const state = reconstructCheckpointState([
started,
{
type: 'human_gate:paused',
...base(1),
nodeId: 'gate',
gateId: 'g1',
gateType: 'approval',
message: 'ok?',
},
{
type: 'human_gate:resumed',
...base(2),
nodeId: 'gate',
decision: 'approved',
decidedBy: 'u1',
},
]);
expect(state?.pendingGates).toEqual([]);
expect(state?.resolvedGateIds).toContain('g1'); // moved to resolved → idempotent re-delivery is a no-op
expect(state?.nodeStates.get('gate')).toEqual({
status: 'completed',
output: { decision: 'approved' },
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('a resumed gate with a payload records the payload as the output', () => {
const state = reconstructCheckpointState([
started,
{
type: 'human_gate:paused',
...base(1),
nodeId: 'gate',
gateId: 'g1',
gateType: 'input',
message: 'value?',
},
{
type: 'human_gate:resumed',
...base(2),
nodeId: 'gate',
decision: 'input_provided',
decidedBy: 'u1',
payload: { x: 7 },
},
]);
expect(state?.nodeStates.get('gate')).toEqual({ status: 'completed', output: { x: 7 } });
});

it('restores running token + cost tallies so a resumed run keeps cumulative totals', () => {
const state = reconstructCheckpointState([
started,
{
type: 'node:completed',
...base(1),
nodeId: 'a',
output: 'A',
tokensUsed: { input: 10, output: 5 },
durationMs: 1,
},
{
type: 'cost:updated',
...base(2),
nodeId: 'a',
model: 'm',
inputTokens: 10,
outputTokens: 5,
costMicrocents: 700,
cumulativeCostMicrocents: 700,
},
{
type: 'node:completed',
...base(3),
nodeId: 'b',
output: 'B',
tokensUsed: { input: 20, output: 8 },
durationMs: 1,
},
{
type: 'cost:updated',
...base(4),
nodeId: 'b',
model: 'm',
inputTokens: 20,
outputTokens: 8,
costMicrocents: 900,
cumulativeCostMicrocents: 1600,
},
]);
expect(state?.totalInputTokens).toBe(30);
expect(state?.totalOutputTokens).toBe(13);
expect(state?.cumulativeCostMicrocents).toBe(1600); // the last running total, not a re-sum
});

it('records a failed node with its typed failure', () => {
const state = reconstructCheckpointState([
started,
{
type: 'node:failed',
...base(1),
nodeId: 'a',
error: { code: 'tool_failed', message: 'boom', retryable: false },
},
]);
expect(state?.nodeStates.get('a')).toEqual({
status: 'failed',
error: { code: 'tool_failed', message: 'boom', retryable: false },
});
});
});

describe('createInMemoryCheckpointer', () => {
it('loads reconstructed state from an InMemoryRunStore event log', async () => {
const store = new InMemoryRunStore();
await store.persistEvent(started);
await store.persistEvent(completed(1, 'a', 'A'));
const cp = createInMemoryCheckpointer(store);
const state = await cp.load('r1');
expect(state?.runStatus).toBe('running');
expect(state?.nodeStates.get('a')).toEqual({ status: 'completed', output: 'A' });
});

it('returns undefined for an unknown run', async () => {
const cp = createInMemoryCheckpointer(new InMemoryRunStore());
expect(await cp.load('nope')).toBeUndefined();
});

it('returns undefined for an opaque (non-in-memory) store — a custom store supplies its own', async () => {
const opaque = {
resolveWorkflowId: () => Promise.resolve('x'),
persistEvent: () => Promise.resolve(),
listInterruptedRuns: () => Promise.resolve([]),
};
const cp = createInMemoryCheckpointer(opaque);
expect(await cp.load('r1')).toBeUndefined();
});
});
Loading
Loading