specrails-core 6.0.0 → 6.1.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/README.md +1 -1
- package/dist/agent-runtime/capabilities.js +3 -1
- package/dist/agent-runtime/change-scope.d.ts +63 -0
- package/dist/agent-runtime/change-scope.js +297 -0
- package/dist/agent-runtime/cli-executor.d.ts +3 -0
- package/dist/agent-runtime/cli-executor.js +50 -13
- package/dist/agent-runtime/cli-process.js +6 -2
- package/dist/agent-runtime/cli.d.ts +15 -0
- package/dist/agent-runtime/cli.js +43 -9
- package/dist/agent-runtime/compact/chat-client.js +2 -1
- package/dist/agent-runtime/compact/environment.d.ts +12 -0
- package/dist/agent-runtime/compact/environment.js +31 -1
- package/dist/agent-runtime/compact/test-reachability.js +6 -2
- package/dist/agent-runtime/config.d.ts +4 -1
- package/dist/agent-runtime/config.js +41 -3
- package/dist/agent-runtime/core-host.d.ts +10 -4
- package/dist/agent-runtime/core-host.js +22 -19
- package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
- package/dist/agent-runtime/efficiency-summary.js +30 -27
- package/dist/agent-runtime/efficiency-types.d.ts +4 -4
- package/dist/agent-runtime/efficiency.d.ts +1 -1
- package/dist/agent-runtime/efficiency.js +7 -6
- package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
- package/dist/agent-runtime/engine/budget.d.ts +9 -0
- package/dist/agent-runtime/engine/budget.js +33 -0
- package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
- package/dist/agent-runtime/engine/canonical-json.js +162 -0
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
- package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
- package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
- package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
- package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
- package/dist/agent-runtime/engine/cli.d.ts +10 -0
- package/dist/agent-runtime/engine/cli.js +172 -0
- package/dist/agent-runtime/engine/compiler.d.ts +23 -0
- package/dist/agent-runtime/engine/compiler.js +261 -0
- package/dist/agent-runtime/engine/composition.d.ts +16 -0
- package/dist/agent-runtime/engine/composition.js +28 -0
- package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
- package/dist/agent-runtime/engine/concurrency.js +55 -0
- package/dist/agent-runtime/engine/contracts.d.ts +273 -0
- package/dist/agent-runtime/engine/contracts.js +11 -0
- package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
- package/dist/agent-runtime/engine/definition-schema.js +313 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
- package/dist/agent-runtime/engine/definition-types.js +3 -0
- package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
- package/dist/agent-runtime/engine/definition-validator.js +187 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
- package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
- package/dist/agent-runtime/engine/events.d.ts +19 -0
- package/dist/agent-runtime/engine/events.js +88 -0
- package/dist/agent-runtime/engine/execution.d.ts +29 -0
- package/dist/agent-runtime/engine/execution.js +121 -0
- package/dist/agent-runtime/engine/expressions.d.ts +11 -0
- package/dist/agent-runtime/engine/expressions.js +172 -0
- package/dist/agent-runtime/engine/fork.d.ts +28 -0
- package/dist/agent-runtime/engine/fork.js +211 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
- package/dist/agent-runtime/engine/graph-description.js +16 -0
- package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
- package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
- package/dist/agent-runtime/engine/index.d.ts +14 -0
- package/dist/agent-runtime/engine/index.js +14 -0
- package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
- package/dist/agent-runtime/engine/invocation-context.js +128 -0
- package/dist/agent-runtime/engine/otel.d.ts +15 -0
- package/dist/agent-runtime/engine/otel.js +132 -0
- package/dist/agent-runtime/engine/output.d.ts +4 -0
- package/dist/agent-runtime/engine/output.js +73 -0
- package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
- package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
- package/dist/agent-runtime/engine/piece-registry.js +62 -0
- package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
- package/dist/agent-runtime/engine/pieces/assign.js +35 -0
- package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/control.js +49 -0
- package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/decider.js +70 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
- package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
- package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
- package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/index.js +22 -0
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
- package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
- package/dist/agent-runtime/engine/pieces/ports.js +1 -0
- package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
- package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
- package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
- package/dist/agent-runtime/engine/pieces/shared.js +40 -0
- package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
- package/dist/agent-runtime/engine/pieces/shell.js +47 -0
- package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/verify.js +52 -0
- package/dist/agent-runtime/engine/preflight.d.ts +27 -0
- package/dist/agent-runtime/engine/preflight.js +54 -0
- package/dist/agent-runtime/engine/regex.d.ts +3 -0
- package/dist/agent-runtime/engine/regex.js +19 -0
- package/dist/agent-runtime/engine/run-status.d.ts +563 -0
- package/dist/agent-runtime/engine/run-status.js +61 -0
- package/dist/agent-runtime/engine/runs.d.ts +605 -0
- package/dist/agent-runtime/engine/runs.js +247 -0
- package/dist/agent-runtime/engine/state.d.ts +64 -0
- package/dist/agent-runtime/engine/state.js +98 -0
- package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
- package/dist/agent-runtime/engine/steering/inbox.js +113 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
- package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
- package/dist/agent-runtime/engine/storage/private-path.js +74 -0
- package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
- package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
- package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
- package/dist/agent-runtime/evaluation-corpus.js +4 -0
- package/dist/agent-runtime/evaluation.d.ts +9 -3
- package/dist/agent-runtime/evaluation.js +50 -8
- package/dist/agent-runtime/executor-types.d.ts +53 -3
- package/dist/agent-runtime/executor-types.js +35 -2
- package/dist/agent-runtime/executors.js +2 -2
- package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
- package/dist/agent-runtime/graph/artifacts.js +86 -10
- package/dist/agent-runtime/graph/convergence.d.ts +26 -0
- package/dist/agent-runtime/graph/convergence.js +62 -0
- package/dist/agent-runtime/graph/nodes.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +171 -24
- package/dist/agent-runtime/graph/roles.d.ts +6 -0
- package/dist/agent-runtime/graph/roles.js +23 -17
- package/dist/agent-runtime/graph/state.d.ts +5 -0
- package/dist/agent-runtime/graph/state.js +2 -0
- package/dist/agent-runtime/kimi-acp.js +6 -6
- package/dist/agent-runtime/openai-executor.js +17 -12
- package/dist/agent-runtime/openspec.d.ts +17 -2
- package/dist/agent-runtime/openspec.js +54 -20
- package/dist/agent-runtime/prompts.d.ts +11 -5
- package/dist/agent-runtime/prompts.js +71 -20
- package/dist/agent-runtime/recovery.d.ts +1 -1
- package/dist/agent-runtime/repository-context.js +20 -1
- package/dist/agent-runtime/role-routing.js +4 -2
- package/dist/agent-runtime/role-state.d.ts +5 -0
- package/dist/agent-runtime/verification-plan.d.ts +2 -0
- package/dist/agent-runtime/verification-plan.js +18 -3
- package/dist/agent-runtime/workflow-types.d.ts +2 -0
- package/dist/agent-runtime/workflow.d.ts +3 -1
- package/dist/agent-runtime/workflow.js +7 -3
- package/dist/agent-runtime/workspace-tools.d.ts +2 -2
- package/dist/agent-runtime/workspace-tools.js +6 -6
- package/dist/installer/cli.js +5 -0
- package/dist/installer/phases/scaffold.d.ts +2 -2
- package/dist/installer/phases/scaffold.js +3 -7
- package/dist/pipeline/pipeline-state.d.ts +49 -4
- package/dist/pipeline/pipeline-state.js +150 -23
- package/integration-contract.json +84 -12
- package/package.json +12 -5
- package/schemas/agent-runtime.schema.json +93 -1
- package/schemas/workflow-definition.schema.json +312 -0
- package/templates/kimi/specrails/run-skill.mjs +15 -1
- package/templates/commands/specrails/batch-implement.md +0 -32
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonicalJson, contentDigest } from '../canonical-json.js';
|
|
3
|
+
import { EngineError } from '../contracts.js';
|
|
4
|
+
import { appendRunEvent } from './database.js';
|
|
5
|
+
import { RunLease } from './lease.js';
|
|
6
|
+
import { ControlInbox } from '../steering/inbox.js';
|
|
7
|
+
const taskParams = (task) => [task.checkpointThreadId, task.taskCheckpointNs, task.checkpointId, task.taskId];
|
|
8
|
+
const contextOf = (frame) => ({ nodePath: frame.nodePath, scopeId: frame.scope.id, ...(frame.scope.branchId ? { branchId: frame.scope.branchId } : {}), visit: frame.visit, attempt: frame.attempt, attemptId: frame.attemptId });
|
|
9
|
+
/** The ledger is authoritative for visits, attempts, usage, receipts and durable event cursors. */
|
|
10
|
+
export class RunLedger {
|
|
11
|
+
db;
|
|
12
|
+
token;
|
|
13
|
+
options;
|
|
14
|
+
lease;
|
|
15
|
+
now;
|
|
16
|
+
constructor(db, token, options) {
|
|
17
|
+
this.db = db;
|
|
18
|
+
this.token = token;
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.now = options.now ?? Date.now;
|
|
21
|
+
this.lease = new RunLease(db, token.runId, this.now);
|
|
22
|
+
}
|
|
23
|
+
static initialize(db, input) {
|
|
24
|
+
if (db.sqlite.prepare('SELECT run_id FROM runs').get())
|
|
25
|
+
throw new EngineError('run_exists', 'This database already contains a run');
|
|
26
|
+
const at = new Date().toISOString();
|
|
27
|
+
for (const value of Object.values(input.budget ?? {}))
|
|
28
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
29
|
+
throw new EngineError('invalid_arguments', 'Budget limits must be non-negative finite values');
|
|
30
|
+
db.transaction('run-created', () => {
|
|
31
|
+
db.put('runs', { run_id: input.runId, workflow_id: input.workflowId, definition_hash: input.definitionHash,
|
|
32
|
+
definition_json: canonicalJson(input.definition), request_json: canonicalJson(input.request), source: input.source, engine_version: 2,
|
|
33
|
+
runtime_identity_json: canonicalJson(input.runtimeIdentity), status: 'running', fork_of: input.forkOf ?? null,
|
|
34
|
+
checkpoint_thread_id: input.checkpointThreadId ?? input.runId, created_at: at, updated_at: at, current_revision: db.transactionRevision });
|
|
35
|
+
db.put('budget', { run_id: input.runId, max_cost_usd: input.budget?.maxCostUsd ?? null, max_tokens: input.budget?.maxTokens ?? null,
|
|
36
|
+
max_duration_ms: input.budget?.maxDurationMs ?? null });
|
|
37
|
+
appendRunEvent(db, input.runId, 'workflow_started', {}, {}, at);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
get runId() { return this.token.runId; }
|
|
41
|
+
run() {
|
|
42
|
+
const row = this.db.get('runs', { run_id: this.runId });
|
|
43
|
+
if (!row)
|
|
44
|
+
throw new EngineError('run_not_found', 'Run does not exist');
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
47
|
+
assertFrame(frame) {
|
|
48
|
+
this.lease.assert(this.token);
|
|
49
|
+
const attempt = this.db.get('attempts', { attempt_id: frame.attemptId });
|
|
50
|
+
if (!attempt || frame.runId !== this.runId || frame.leaseEpoch !== this.token.epoch || attempt.lease_epoch !== this.token.epoch ||
|
|
51
|
+
contentDigest(JSON.parse(String(attempt.frame_json))) !== contentDigest(frame))
|
|
52
|
+
throw new EngineError('attempt_mismatch', 'Attempt does not match the current fenced execution');
|
|
53
|
+
return attempt;
|
|
54
|
+
}
|
|
55
|
+
event(frame, type, payload = {}) {
|
|
56
|
+
return appendRunEvent(this.db, this.runId, type, payload, contextOf(frame), new Date(this.now()).toISOString());
|
|
57
|
+
}
|
|
58
|
+
/** Public node namespace differs from the parent saver namespace; never parse opaque namespace strings. */
|
|
59
|
+
bindPendingTask(task) {
|
|
60
|
+
const matches = this.db.sqlite.prepare('SELECT * FROM visits WHERE thread_id=? AND checkpoint_id=? AND task_id=?')
|
|
61
|
+
.all(task.checkpointThreadId, task.checkpointId, task.taskId);
|
|
62
|
+
if (matches.length > 1)
|
|
63
|
+
throw new EngineError('task_identity_conflict', 'Pending writes match more than one public task identity');
|
|
64
|
+
const visit = matches[0];
|
|
65
|
+
if (!visit)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (visit.saver_checkpoint_ns !== null && visit.saver_checkpoint_ns !== task.taskCheckpointNs)
|
|
68
|
+
throw new EngineError('task_identity_conflict', 'A task cannot move to another saver namespace');
|
|
69
|
+
if (visit.saver_checkpoint_ns === null)
|
|
70
|
+
this.db.put('visits', { ...visit, saver_checkpoint_ns: task.taskCheckpointNs });
|
|
71
|
+
return visit;
|
|
72
|
+
}
|
|
73
|
+
checkAdmission() {
|
|
74
|
+
const row = this.run(), budget = this.db.get('budget', { run_id: this.runId });
|
|
75
|
+
if (this.db.sqlite.prepare("SELECT 1 FROM control_inbox WHERE run_id=? AND kind='cancel' AND consumed_at IS NULL").get(this.runId))
|
|
76
|
+
throw new EngineError('aborted', 'Cancellation requested');
|
|
77
|
+
if (this.options.failFast && Number(row.consecutive_failures) >= this.options.failFast)
|
|
78
|
+
throw new EngineError('fail_fast', 'Consecutive AI failure limit reached');
|
|
79
|
+
const duration = this.activeDurationMs();
|
|
80
|
+
if ((budget.max_tokens !== null && Number(budget.known_tokens) >= Number(budget.max_tokens)) ||
|
|
81
|
+
(budget.max_cost_usd !== null && Number(budget.known_cost_usd) >= Number(budget.max_cost_usd)) ||
|
|
82
|
+
(budget.max_duration_ms !== null && duration >= Number(budget.max_duration_ms)))
|
|
83
|
+
throw new EngineError('budget_exhausted', 'Run budget is exhausted');
|
|
84
|
+
}
|
|
85
|
+
enter(input) {
|
|
86
|
+
return this.db.transaction('attempt-admitted', () => {
|
|
87
|
+
this.lease.assert(this.token);
|
|
88
|
+
if (!input.nodePath || !input.scope.id || taskParams(input.task).some(value => typeof value !== 'string') || !input.task.taskId || !input.task.checkpointId)
|
|
89
|
+
throw new EngineError('invalid_arguments', 'Complete task identity is required');
|
|
90
|
+
const run = this.run(), at = new Date(this.now()).toISOString(), beforeRevision = this.db.transactionRevision - 1;
|
|
91
|
+
let visit = this.db.sqlite.prepare('SELECT * FROM visits WHERE thread_id=? AND checkpoint_ns=? AND checkpoint_id=? AND task_id=?').get(...taskParams(input.task));
|
|
92
|
+
if (visit && (visit.run_id !== this.runId || visit.node_path !== input.nodePath || visit.scope_id !== input.scope.id || visit.kind !== input.kind || visit.effect !== input.effect))
|
|
93
|
+
throw new EngineError('task_identity_conflict', 'A task cannot change node or scope on replay');
|
|
94
|
+
if (!visit) {
|
|
95
|
+
if (Number(run.transitions) >= this.options.maxTransitions)
|
|
96
|
+
throw new EngineError('recursion_limit', 'Maximum global node visits reached');
|
|
97
|
+
const localVisit = Number(this.db.sqlite.prepare('SELECT COALESCE(MAX(local_visit),0)+1 AS value FROM visits WHERE run_id=? AND node_path=? AND scope_id=?').get(this.runId, input.nodePath, input.scope.id).value);
|
|
98
|
+
const visitId = randomUUID(), transition = Number(run.transitions) + 1;
|
|
99
|
+
this.db.put('visits', { visit_id: visitId, run_id: this.runId, node_path: input.nodePath, scope_id: input.scope.id, branch_id: input.scope.branchId ?? null,
|
|
100
|
+
kind: input.kind, effect: input.effect, requires_ai: input.requiresAI ? 1 : 0, local_visit: localVisit, global_transition: transition,
|
|
101
|
+
thread_id: input.task.checkpointThreadId, checkpoint_ns: input.task.taskCheckpointNs, checkpoint_id: input.task.checkpointId, task_id: input.task.taskId, before_revision: beforeRevision, created_at: at });
|
|
102
|
+
this.db.put('runs', { ...run, transitions: transition, current_revision: this.db.transactionRevision, updated_at: at });
|
|
103
|
+
visit = this.db.get('visits', { visit_id: visitId });
|
|
104
|
+
}
|
|
105
|
+
const prior = this.db.sqlite.prepare('SELECT * FROM attempts WHERE visit_id=? ORDER BY attempt DESC LIMIT 1').get(visit.visit_id);
|
|
106
|
+
if (prior?.terminal_digest && prior.status !== 'interrupted')
|
|
107
|
+
return JSON.parse(String(prior.frame_json));
|
|
108
|
+
// An approval/question continuation keeps its physical attempt and claimed steering.
|
|
109
|
+
if (prior?.status === 'paused') {
|
|
110
|
+
const frame = { ...JSON.parse(String(prior.frame_json)), runId: this.runId, leaseEpoch: this.token.epoch };
|
|
111
|
+
this.db.put('attempts', { ...prior, status: 'running', lease_epoch: this.token.epoch, frame_json: canonicalJson(frame) });
|
|
112
|
+
this.db.put('runs', { ...this.run(), status: 'running', active_started_at: this.run().active_started_at ?? at });
|
|
113
|
+
return frame;
|
|
114
|
+
}
|
|
115
|
+
this.checkAdmission();
|
|
116
|
+
if (prior?.status === 'running' && prior.lease_epoch === this.token.epoch && !prior.inherited)
|
|
117
|
+
throw new EngineError('attempt_running', 'The task is already executing');
|
|
118
|
+
if (prior && (prior.status === 'running' || prior.status === 'interrupted') && input.effect === 'write' && !prior.recovery_authorized)
|
|
119
|
+
throw new EngineError('recover_required', 'An interrupted write requires explicit recovery');
|
|
120
|
+
if (prior?.status === 'running') {
|
|
121
|
+
this.db.put('attempts', { ...prior, status: 'interrupted', ended_at: at, error_code: 'owner_lost', error_message: 'Previous executor did not settle this attempt' });
|
|
122
|
+
this.event(JSON.parse(String(prior.frame_json)), 'step_interrupted', { code: 'owner_lost' });
|
|
123
|
+
}
|
|
124
|
+
const attempt = Number(prior?.attempt ?? 0) + 1;
|
|
125
|
+
if (attempt > input.retry.maxAttempts && !prior?.recovery_authorized)
|
|
126
|
+
throw new EngineError('retry_exhausted', 'Maximum physical attempts reached for this visit');
|
|
127
|
+
const frame = { runId: this.runId, nodePath: input.nodePath, scope: input.scope, task: input.task,
|
|
128
|
+
visitId: String(visit.visit_id), visit: Number(visit.local_visit), transition: Number(visit.global_transition), attemptId: randomUUID(), attempt, leaseEpoch: this.token.epoch };
|
|
129
|
+
this.db.put('attempts', { attempt_id: frame.attemptId, visit_id: frame.visitId, run_id: this.runId, node_path: frame.nodePath, scope_id: frame.scope.id,
|
|
130
|
+
visit: frame.visit, attempt, branch: frame.scope.branchId ?? null, status: 'running', lease_epoch: this.token.epoch, frame_json: canonicalJson(frame), started_at: at });
|
|
131
|
+
this.db.put('steps', { run_id: this.runId, node_path: frame.nodePath, scope_id: frame.scope.id, branch_id: frame.scope.branchId ?? null,
|
|
132
|
+
kind: input.kind, visits: frame.visit, status: 'running', last_attempt_id: frame.attemptId, updated_at: at });
|
|
133
|
+
this.db.put('runs', { ...this.run(), status: 'running', active_started_at: this.run().active_started_at ?? at });
|
|
134
|
+
if (input.effect === 'write')
|
|
135
|
+
this.db.put('runs', { ...this.run(), verified_json: null, current_revision: this.db.transactionRevision, updated_at: at });
|
|
136
|
+
if (input.acceptsSteering)
|
|
137
|
+
new ControlInbox(this.db, this.runId).claimForAttempt(frame);
|
|
138
|
+
this.event(frame, 'step_started');
|
|
139
|
+
return frame;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
resultFor(frame) {
|
|
143
|
+
const row = this.db.get('attempts', { attempt_id: frame.attemptId });
|
|
144
|
+
return row?.terminal_digest && row.status !== 'interrupted' && row.output_json ? JSON.parse(String(row.output_json)) : undefined;
|
|
145
|
+
}
|
|
146
|
+
terminal(frame, result) {
|
|
147
|
+
this.lease.assert(this.token);
|
|
148
|
+
const value = { schemaVersion: 1, frame, result };
|
|
149
|
+
if (Buffer.byteLength(canonicalJson(value)) > 2 * 1024 * 1024)
|
|
150
|
+
throw new EngineError('output_limit', 'Terminal marker exceeds 2 MiB');
|
|
151
|
+
const marker = { ...value, digest: contentDigest(value) }, prior = this.db.get('attempts', { attempt_id: frame.attemptId });
|
|
152
|
+
if (prior?.terminal_digest) {
|
|
153
|
+
if (prior.run_id !== this.runId || prior.terminal_digest !== marker.digest)
|
|
154
|
+
throw new EngineError('terminal_conflict', 'Replay must preserve the original terminal marker');
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
this.assertFrame(frame);
|
|
158
|
+
return marker;
|
|
159
|
+
}
|
|
160
|
+
/** Saver-only synchronous settlement; it shares the already-open pending-write transaction. */
|
|
161
|
+
commitTerminal(marker, task) {
|
|
162
|
+
const { frame, result } = marker;
|
|
163
|
+
this.lease.assert(this.token);
|
|
164
|
+
const attempt = this.db.get('attempts', { attempt_id: frame.attemptId });
|
|
165
|
+
if (!attempt || attempt.run_id !== this.runId)
|
|
166
|
+
throw new EngineError('attempt_mismatch', 'Terminal attempt does not belong to this run');
|
|
167
|
+
const pendingVisit = this.bindPendingTask(task);
|
|
168
|
+
if (marker.schemaVersion !== 1 || contentDigest({ schemaVersion: 1, frame, result }) !== marker.digest || pendingVisit?.visit_id !== frame.visitId ||
|
|
169
|
+
frame.task.checkpointThreadId !== task.checkpointThreadId || frame.task.checkpointId !== task.checkpointId || frame.task.taskId !== task.taskId)
|
|
170
|
+
throw new EngineError('terminal_mismatch', 'Terminal marker does not match its task or content digest');
|
|
171
|
+
if (attempt.terminal_digest) {
|
|
172
|
+
if (attempt.terminal_digest !== marker.digest)
|
|
173
|
+
throw new EngineError('terminal_conflict', 'Settled attempt received conflicting terminal content');
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
this.assertFrame(frame);
|
|
177
|
+
if (attempt.status !== 'running' && attempt.status !== 'paused')
|
|
178
|
+
throw new EngineError('terminal_conflict', 'Only a live attempt may settle');
|
|
179
|
+
const at = new Date(this.now()).toISOString(), status = result.status ?? 'succeeded', run = this.run();
|
|
180
|
+
const completesRun = result.completesRun === true && result.completion !== undefined;
|
|
181
|
+
let candidate = run.candidate_json ? JSON.parse(String(run.candidate_json)) : undefined;
|
|
182
|
+
if (result.candidate) {
|
|
183
|
+
if (result.candidate.revision <= Number(run.candidate_revision) || result.candidate.atTransition !== frame.transition)
|
|
184
|
+
throw new EngineError('candidate_conflict', 'Candidate proposal is stale or belongs to another transition');
|
|
185
|
+
candidate = result.candidate;
|
|
186
|
+
this.db.put('runs', { ...run, candidate_json: canonicalJson(result.candidate), candidate_revision: result.candidate.revision, verified_json: null });
|
|
187
|
+
}
|
|
188
|
+
const visit = this.db.get('visits', { visit_id: frame.visitId });
|
|
189
|
+
if (result.receipt) {
|
|
190
|
+
const receipt = result.receipt;
|
|
191
|
+
if (receipt.valid && (!candidate || receipt.candidateHash !== candidate.hash))
|
|
192
|
+
throw new EngineError('receipt_invalid', 'A valid verification receipt must match the committed candidate');
|
|
193
|
+
this.db.put('receipts', { receipt_id: receipt.id, run_id: this.runId, node_path: frame.nodePath, attempt_id: frame.attemptId,
|
|
194
|
+
kind: 'verification', valid: receipt.valid ? 1 : 0, candidate_hash: receipt.candidateHash, candidate_revision: candidate?.revision ?? Number(run.candidate_revision), receipt_json: canonicalJson(receipt), created_at: at });
|
|
195
|
+
}
|
|
196
|
+
if (result.verified === null)
|
|
197
|
+
this.db.put('runs', { ...this.run(), verified_json: null });
|
|
198
|
+
else if (result.verified) {
|
|
199
|
+
const receipt = result.receipt, verified = result.verified;
|
|
200
|
+
const evidence = receipt?.evidence && typeof receipt.evidence === 'object' && !Array.isArray(receipt.evidence) ? receipt.evidence : undefined;
|
|
201
|
+
if (!['verify', 'implementation'].includes(String(visit.kind)) || !receipt?.valid || receipt.scope !== 'full' || !candidate ||
|
|
202
|
+
!Array.isArray(evidence?.commands) || evidence.commands.length === 0 ||
|
|
203
|
+
(evidence.unverifiedRepositories !== undefined && (!Array.isArray(evidence.unverifiedRepositories) || evidence.unverifiedRepositories.length !== 0)) ||
|
|
204
|
+
verified.receiptId !== receipt.id || verified.candidateHash !== candidate.hash || verified.revision !== candidate.revision || verified.atTransition !== frame.transition) {
|
|
205
|
+
throw new EngineError('receipt_invalid', 'Only an authorized full verification of this candidate can certify the run');
|
|
206
|
+
}
|
|
207
|
+
this.db.put('runs', { ...this.run(), verified_json: canonicalJson(verified) });
|
|
208
|
+
}
|
|
209
|
+
this.db.put('attempts', { ...attempt, status, ended_at: at, outcome: result.outcome, output_json: canonicalJson(result), terminal_digest: marker.digest,
|
|
210
|
+
error_code: result.error?.code ?? null, error_message: result.error?.message ?? null });
|
|
211
|
+
const step = this.db.get('steps', { run_id: this.runId, node_path: frame.nodePath, scope_id: frame.scope.id });
|
|
212
|
+
this.db.put('steps', { ...step, status, updated_at: at });
|
|
213
|
+
this.db.put('runs', { ...this.run(), current_revision: this.db.transactionRevision, updated_at: at,
|
|
214
|
+
consecutive_failures: visit.requires_ai ? (status === 'succeeded' ? 0 : Number(this.run().consecutive_failures) + 1) : this.run().consecutive_failures,
|
|
215
|
+
...(completesRun ? { completion_json: canonicalJson(result.completion), status: result.completion.ok ? 'succeeded' : 'failed',
|
|
216
|
+
active_duration_ms: this.activeDurationMs(), active_started_at: null } : {}) });
|
|
217
|
+
return [this.event(frame, `step_${status}`, { outcome: result.outcome, ...(result.error ? { error: result.error } : {}) }),
|
|
218
|
+
...(completesRun ? [appendRunEvent(this.db, this.runId, result.completion.ok ? 'workflow_succeeded' : 'workflow_failed', result.completion, {}, at)] : [])];
|
|
219
|
+
}
|
|
220
|
+
fail(frame, error, retryable) {
|
|
221
|
+
this.db.transaction('attempt-failed', () => {
|
|
222
|
+
const row = this.assertFrame(frame);
|
|
223
|
+
if (row.status === 'failed')
|
|
224
|
+
return;
|
|
225
|
+
if (row.status !== 'running')
|
|
226
|
+
throw new EngineError('terminal_conflict', 'Attempt is not running');
|
|
227
|
+
const at = new Date(this.now()).toISOString();
|
|
228
|
+
this.db.put('attempts', { ...row, status: 'failed', ended_at: at, error_code: error.code, error_message: error.message });
|
|
229
|
+
const visit = this.db.get('visits', { visit_id: frame.visitId }), run = this.run();
|
|
230
|
+
if (visit.requires_ai)
|
|
231
|
+
this.db.put('runs', { ...run, consecutive_failures: Number(run.consecutive_failures) + 1 });
|
|
232
|
+
this.event(frame, retryable ? 'step_retrying' : 'step_failed', error);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** Stages evidence before an error is rethrown; saver owns its terminal __error__ transaction. */
|
|
236
|
+
prepareInterruption(frame, error) {
|
|
237
|
+
this.db.transaction('interruption-prepared', () => {
|
|
238
|
+
const attempt = this.assertFrame(frame);
|
|
239
|
+
this.db.put('attempts', { ...attempt, interruption_json: canonicalJson(error) });
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
commitError(task, error) {
|
|
243
|
+
this.lease.assert(this.token);
|
|
244
|
+
const visit = this.bindPendingTask(task);
|
|
245
|
+
if (!visit)
|
|
246
|
+
return [];
|
|
247
|
+
const attempt = this.db.sqlite.prepare('SELECT * FROM attempts WHERE visit_id=? ORDER BY attempt DESC LIMIT 1').get(visit.visit_id);
|
|
248
|
+
if (attempt.status !== 'running')
|
|
249
|
+
return [];
|
|
250
|
+
const frame = JSON.parse(String(attempt.frame_json));
|
|
251
|
+
this.assertFrame(frame);
|
|
252
|
+
const staged = attempt.interruption_json ? JSON.parse(String(attempt.interruption_json)) : undefined;
|
|
253
|
+
const message = error instanceof Error ? error.message : error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error);
|
|
254
|
+
const failure = staged ?? { code: 'internal', message }, status = staged && visit.effect === 'write' ? 'interrupted' : 'failed', at = new Date(this.now()).toISOString();
|
|
255
|
+
this.db.put('attempts', { ...attempt, status, ended_at: at, error_code: failure.code, error_message: failure.message });
|
|
256
|
+
const step = this.db.get('steps', { run_id: this.runId, node_path: frame.nodePath, scope_id: frame.scope.id });
|
|
257
|
+
this.db.put('steps', { ...step, status, updated_at: at });
|
|
258
|
+
this.db.put('runs', { ...this.run(), status, active_duration_ms: this.activeDurationMs(), active_started_at: null, updated_at: at });
|
|
259
|
+
return [this.event(frame, `step_${status}`, failure)];
|
|
260
|
+
}
|
|
261
|
+
/** Called by saver when LangGraph records the actual public interrupt IDs. */
|
|
262
|
+
commitInterrupts(task, values) {
|
|
263
|
+
this.lease.assert(this.token);
|
|
264
|
+
const visit = this.bindPendingTask(task);
|
|
265
|
+
if (!visit)
|
|
266
|
+
return [];
|
|
267
|
+
const attempt = this.db.sqlite.prepare('SELECT * FROM attempts WHERE visit_id=? ORDER BY attempt DESC LIMIT 1').get(visit.visit_id);
|
|
268
|
+
const frame = JSON.parse(String(attempt.frame_json));
|
|
269
|
+
this.assertFrame(frame);
|
|
270
|
+
const at = new Date(this.now()).toISOString(), events = [];
|
|
271
|
+
for (const value of values) {
|
|
272
|
+
if (this.db.get('interrupts', { interrupt_id: value.id }))
|
|
273
|
+
continue;
|
|
274
|
+
const payload = value.value && typeof value.value === 'object' && !Array.isArray(value.value) ? value.value : {};
|
|
275
|
+
this.db.put('interrupts', { interrupt_id: value.id, run_id: this.runId, visit_id: frame.visitId, attempt_id: frame.attemptId,
|
|
276
|
+
node_path: frame.nodePath, scope_id: frame.scope.id, branch_id: frame.scope.branchId ?? null, kind: String(payload.kind ?? 'gate'),
|
|
277
|
+
requested_at: at, payload_json: canonicalJson(value.value) });
|
|
278
|
+
events.push(this.event(frame, 'step_paused', { interruptId: value.id, request: value.value }));
|
|
279
|
+
}
|
|
280
|
+
this.db.put('attempts', { ...attempt, status: 'paused' });
|
|
281
|
+
const step = this.db.get('steps', { run_id: this.runId, node_path: frame.nodePath, scope_id: frame.scope.id });
|
|
282
|
+
this.db.put('steps', { ...step, status: 'paused', updated_at: at });
|
|
283
|
+
this.db.put('runs', { ...this.run(), status: 'paused', updated_at: at, active_duration_ms: this.activeDurationMs(), active_started_at: null });
|
|
284
|
+
return events;
|
|
285
|
+
}
|
|
286
|
+
/** Actual LangGraph interrupt identities, with answers retained across crashes before resume. */
|
|
287
|
+
interrupts() {
|
|
288
|
+
return this.db.sqlite.prepare('SELECT * FROM interrupts WHERE run_id=? ORDER BY requested_at,interrupt_id').all(this.runId).map(row => ({
|
|
289
|
+
id: String(row.interrupt_id), nodePath: String(row.node_path), scopeId: String(row.scope_id), attemptId: String(row.attempt_id),
|
|
290
|
+
kind: String(row.kind), requestedAt: String(row.requested_at), value: JSON.parse(String(row.payload_json)),
|
|
291
|
+
...(row.branch_id ? { branchId: String(row.branch_id) } : {}), ...(row.answer_json !== null ? { answer: JSON.parse(String(row.answer_json)) } : {}),
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
pendingInterrupts() {
|
|
295
|
+
return this.interrupts().filter(value => value.answer === undefined);
|
|
296
|
+
}
|
|
297
|
+
/** Persist validated answers before invoking Command; a retry must reuse exactly those answers. */
|
|
298
|
+
answerInterrupts(answers) {
|
|
299
|
+
const entries = Object.entries(answers);
|
|
300
|
+
if (!entries.length || Buffer.byteLength(canonicalJson(answers)) > 2 * 1024 * 1024)
|
|
301
|
+
throw new EngineError('invalid_arguments', 'Resume requires bounded answers by interrupt ID');
|
|
302
|
+
this.db.transaction('interrupts-answered', () => {
|
|
303
|
+
this.lease.assert(this.token);
|
|
304
|
+
for (const [id, value] of entries) {
|
|
305
|
+
const row = this.db.get('interrupts', { interrupt_id: id }), encoded = canonicalJson(value);
|
|
306
|
+
if (!row || row.run_id !== this.runId)
|
|
307
|
+
throw new EngineError('interrupt_not_found', 'Resume targets an unknown interrupt');
|
|
308
|
+
if (row.answer_json !== null) {
|
|
309
|
+
if (row.answer_json !== encoded)
|
|
310
|
+
throw new EngineError('answer_conflict', 'A durable interrupt answer cannot change');
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const attempt = this.db.get('attempts', { attempt_id: row.attempt_id });
|
|
314
|
+
if (attempt.status !== 'paused')
|
|
315
|
+
throw new EngineError('interrupt_not_pending', 'The targeted interrupt is no longer paused');
|
|
316
|
+
this.db.put('interrupts', { ...row, answer_json: encoded, answered_at: new Date(this.now()).toISOString() });
|
|
317
|
+
appendRunEvent(this.db, this.runId, 'interrupt_answered', { interruptId: id });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** The run owner calls this only after graph.invoke has reached an idle interrupt barrier. */
|
|
322
|
+
settlePause() {
|
|
323
|
+
if (!this.pendingInterrupts().length || this.run().completion_json)
|
|
324
|
+
return;
|
|
325
|
+
this.db.transaction('workflow-paused', () => {
|
|
326
|
+
this.lease.assert(this.token);
|
|
327
|
+
const run = this.run();
|
|
328
|
+
if (run.completion_json || !this.pendingInterrupts().length)
|
|
329
|
+
return;
|
|
330
|
+
const duration = this.activeDurationMs();
|
|
331
|
+
this.db.put('runs', { ...run, status: 'paused', active_duration_ms: duration, active_started_at: null, updated_at: new Date(this.now()).toISOString() });
|
|
332
|
+
const last = this.db.sqlite.prepare("SELECT type FROM events WHERE run_id=? AND type IN ('workflow_paused','workflow_resumed') ORDER BY sequence DESC LIMIT 1").get(this.runId);
|
|
333
|
+
if (last?.type !== 'workflow_paused')
|
|
334
|
+
appendRunEvent(this.db, this.runId, 'workflow_paused', { interruptIds: this.pendingInterrupts().map(value => value.id) });
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
authorizeRecovery(attemptId) {
|
|
338
|
+
this.db.transaction('recovery-authorized', () => {
|
|
339
|
+
this.lease.assert(this.token);
|
|
340
|
+
const attempt = this.db.get('attempts', { attempt_id: attemptId });
|
|
341
|
+
if (!attempt || attempt.run_id !== this.runId || !['running', 'interrupted'].includes(String(attempt.status)))
|
|
342
|
+
throw new EngineError('invalid_arguments', 'Attempt does not require recovery');
|
|
343
|
+
this.db.put('attempts', { ...attempt, recovery_authorized: 1 });
|
|
344
|
+
appendRunEvent(this.db, this.runId, 'recovery_authorized', { attemptId });
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
startInvocation(frame, input, reservation = {}) {
|
|
348
|
+
return this.db.transaction('invocation-started', () => {
|
|
349
|
+
this.assertFrame(frame);
|
|
350
|
+
this.checkAdmission();
|
|
351
|
+
for (const [key, value] of Object.entries(reservation))
|
|
352
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0 || (key === 'maxTokens' && !Number.isSafeInteger(value))))
|
|
353
|
+
throw new EngineError('invalid_arguments', 'Invocation reservations must be non-negative finite bounds');
|
|
354
|
+
if (this.db.get('invocations', { invocation_id: input.invocationId }))
|
|
355
|
+
throw new EngineError('invocation_exists', 'A physical invocation ID cannot be reused');
|
|
356
|
+
const budget = this.db.get('budget', { run_id: this.runId }), live = this.db.sqlite.prepare('SELECT COALESCE(SUM(max_tokens),0) tokens,COALESCE(SUM(max_cost_usd),0) cost FROM reservations WHERE run_id=?').get(this.runId);
|
|
357
|
+
if (budget.max_tokens !== null && (reservation.maxTokens === undefined || Number(budget.known_tokens) + Number(live.tokens) + reservation.maxTokens > Number(budget.max_tokens)))
|
|
358
|
+
throw new EngineError('budget_exhausted', 'Invocation token reservation exceeds the shared budget');
|
|
359
|
+
if (budget.max_cost_usd !== null && (reservation.maxCostUsd === undefined || Number(budget.known_cost_usd) + Number(live.cost) + reservation.maxCostUsd > Number(budget.max_cost_usd)))
|
|
360
|
+
throw new EngineError('budget_exhausted', 'Invocation cost reservation requires a known upper bound within the shared budget');
|
|
361
|
+
const at = new Date(this.now()).toISOString();
|
|
362
|
+
const ordinal = Number(this.db.sqlite.prepare('SELECT COALESCE(MAX(ordinal),0)+1 value FROM invocations WHERE attempt_id=?').get(frame.attemptId).value);
|
|
363
|
+
this.db.put('reservations', { reservation_id: input.invocationId, run_id: this.runId, attempt_id: frame.attemptId,
|
|
364
|
+
max_tokens: reservation.maxTokens ?? null, max_cost_usd: reservation.maxCostUsd ?? null, created_at: at });
|
|
365
|
+
this.db.put('invocations', { invocation_id: input.invocationId, run_id: this.runId, attempt_id: frame.attemptId, node_path: frame.nodePath,
|
|
366
|
+
role: input.role ?? null, provider: input.provider, model: input.model ?? null, kind: input.kind ?? null, status: 'running', started_at: at,
|
|
367
|
+
usage_json: '{}', prompt_bytes: input.promptBytes ?? null, context_bytes: input.contextBytes ?? null, ordinal });
|
|
368
|
+
return ordinal;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
settleInvocation(frame, rawInvocation, memo) {
|
|
372
|
+
this.db.transaction('invocation-settled', () => {
|
|
373
|
+
this.assertFrame(frame);
|
|
374
|
+
this.commitInvocation(frame, rawInvocation, memo);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** Only a new fenced owner can close calls abandoned by an earlier executor.
|
|
378
|
+
* Keep unknown usage reservations: a lost response does not prove zero spend.
|
|
379
|
+
* This records accounting, never authorizes replay of an uncertain write. */
|
|
380
|
+
settleAbandonedInvocations() {
|
|
381
|
+
return this.db.transaction('invocations-interrupted', () => {
|
|
382
|
+
this.lease.assert(this.token);
|
|
383
|
+
const rows = this.db.sqlite.prepare(`SELECT i.*, a.frame_json FROM invocations i
|
|
384
|
+
JOIN attempts a ON a.attempt_id=i.attempt_id
|
|
385
|
+
WHERE i.run_id=? AND i.status='running' AND a.lease_epoch<? AND i.inherited=0`)
|
|
386
|
+
.all(this.runId, this.token.epoch);
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
this.commitInvocation(JSON.parse(String(row.frame_json)), {
|
|
389
|
+
invocationId: String(row.invocation_id), ordinal: Number(row.ordinal), provider: String(row.provider),
|
|
390
|
+
...(row.model === null ? {} : { model: String(row.model) }),
|
|
391
|
+
...(row.kind === null ? {} : { kind: String(row.kind) }),
|
|
392
|
+
...(row.prompt_bytes === null ? {} : { promptBytes: Number(row.prompt_bytes) }),
|
|
393
|
+
...(row.context_bytes === null ? {} : { contextBytes: Number(row.context_bytes) }),
|
|
394
|
+
status: 'interrupted', durationMs: null, toolCalls: null,
|
|
395
|
+
usage: { costUsd: null, inputTokens: null, outputTokens: null,
|
|
396
|
+
uncachedInputTokens: null, cacheReadInputTokens: null, cacheWriteInputTokens: null },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return rows.length;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** Caller owns the transaction and has checked either the current frame or
|
|
403
|
+
* exclusive recovery ownership of the earlier epoch. */
|
|
404
|
+
commitInvocation(frame, rawInvocation, memo) {
|
|
405
|
+
const invocation = { ...rawInvocation, usage: { ...rawInvocation.usage, costUsd: rawInvocation.usage.costUsd ?? null,
|
|
406
|
+
inputTokens: rawInvocation.usage.inputTokens ?? null, outputTokens: rawInvocation.usage.outputTokens ?? null } };
|
|
407
|
+
const row = this.db.get('invocations', { invocation_id: invocation.invocationId });
|
|
408
|
+
if (!row || row.attempt_id !== frame.attemptId)
|
|
409
|
+
throw new EngineError('invocation_mismatch', 'Invocation must belong to its physical attempt');
|
|
410
|
+
const serialized = canonicalJson(invocation);
|
|
411
|
+
if (row.status !== 'running') {
|
|
412
|
+
if (row.result_json !== serialized)
|
|
413
|
+
throw new EngineError('invocation_conflict', 'Invocation settlement cannot change');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const value of [invocation.durationMs, invocation.toolCalls, ...Object.values(invocation.usage)])
|
|
417
|
+
if (value !== null && value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
418
|
+
throw new EngineError('invalid_usage', 'Invocation usage must be non-negative or unknown');
|
|
419
|
+
const usage = invocation.usage, budget = this.db.get('budget', { run_id: this.runId }), at = new Date(this.now()).toISOString();
|
|
420
|
+
this.db.put('invocations', { ...row, status: invocation.status, ended_at: at, duration_ms: invocation.durationMs,
|
|
421
|
+
tool_calls: invocation.toolCalls, usage_json: canonicalJson(usage), result_json: serialized });
|
|
422
|
+
const reservation = this.db.get('reservations', { reservation_id: invocation.invocationId });
|
|
423
|
+
// Settlement with missing usage does not prove the unused reservation is free.
|
|
424
|
+
// Retain only the unreported dimensions, without relabeling a bound as billed usage.
|
|
425
|
+
if (reservation && (usage.costUsd === null || usage.inputTokens === null || usage.outputTokens === null)) {
|
|
426
|
+
this.db.put('reservations', { ...reservation,
|
|
427
|
+
max_cost_usd: usage.costUsd === null ? reservation.max_cost_usd : 0,
|
|
428
|
+
max_tokens: usage.inputTokens === null || usage.outputTokens === null
|
|
429
|
+
? reservation.max_tokens === null ? null : Math.max(0, Number(reservation.max_tokens) - (usage.inputTokens ?? 0) - (usage.outputTokens ?? 0)) : 0 });
|
|
430
|
+
}
|
|
431
|
+
else
|
|
432
|
+
this.db.delete('reservations', { reservation_id: invocation.invocationId });
|
|
433
|
+
this.db.put('budget', { ...budget, known_cost_usd: Number(budget.known_cost_usd) + (usage.costUsd ?? 0),
|
|
434
|
+
known_tokens: Number(budget.known_tokens) + (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
|
435
|
+
input_tokens: Number(budget.input_tokens) + (usage.inputTokens ?? 0), output_tokens: Number(budget.output_tokens) + (usage.outputTokens ?? 0),
|
|
436
|
+
cost_unknown: budget.cost_unknown || usage.costUsd === null ? 1 : 0, input_unknown: budget.input_unknown || usage.inputTokens === null ? 1 : 0,
|
|
437
|
+
output_unknown: budget.output_unknown || usage.outputTokens === null ? 1 : 0, duration_ms: Number(budget.duration_ms) + (invocation.durationMs ?? 0) });
|
|
438
|
+
if (memo)
|
|
439
|
+
this.storePieceState(frame, memo.key, memo.value);
|
|
440
|
+
this.event(frame, 'efficiency_updated', { ...invocation, startedAt: String(row.started_at), finishedAt: at });
|
|
441
|
+
}
|
|
442
|
+
readPieceState(frame, key) {
|
|
443
|
+
const row = this.db.get('piece_state', { run_id: this.runId, scope_id: frame.scope.id, node_path: frame.nodePath, key });
|
|
444
|
+
return row ? JSON.parse(String(row.value_json)) : undefined;
|
|
445
|
+
}
|
|
446
|
+
storePieceState(frame, key, value, ownerNodePath = frame.nodePath) {
|
|
447
|
+
if (!key || key.length > 1024)
|
|
448
|
+
throw new EngineError('invalid_arguments', 'Piece state key must contain 1–1024 characters');
|
|
449
|
+
const serialized = canonicalJson(value);
|
|
450
|
+
if (Buffer.byteLength(serialized) > 2 * 1024 * 1024)
|
|
451
|
+
throw new EngineError('output_limit', 'Piece state exceeds 2 MiB');
|
|
452
|
+
const previous = this.db.get('piece_state', { run_id: this.runId, scope_id: frame.scope.id, node_path: ownerNodePath, key });
|
|
453
|
+
if (key.startsWith('memo:') && previous && previous.value_json !== serialized)
|
|
454
|
+
throw new EngineError('piece_state_conflict', 'A memoized physical result cannot change');
|
|
455
|
+
this.db.put('piece_state', { run_id: this.runId, scope_id: frame.scope.id, node_path: ownerNodePath, key, visit_id: frame.visitId,
|
|
456
|
+
value_json: serialized, updated_at: new Date(this.now()).toISOString() });
|
|
457
|
+
}
|
|
458
|
+
writePieceState(frame, key, value) {
|
|
459
|
+
this.db.transaction('piece-state-updated', () => { this.assertFrame(frame); this.storePieceState(frame, key, value); });
|
|
460
|
+
}
|
|
461
|
+
sessionOwner(frame, ownerNodePath, key) {
|
|
462
|
+
const owner = ownerNodePath.replace(/\/$/, '');
|
|
463
|
+
if (!key.startsWith('session:') || (owner !== '' && owner !== frame.nodePath && !frame.nodePath.startsWith(owner + '/'))) {
|
|
464
|
+
throw new EngineError('piece_state_scope', 'Shared sessions require an ancestor node owner in this execution scope');
|
|
465
|
+
}
|
|
466
|
+
return owner;
|
|
467
|
+
}
|
|
468
|
+
readScopedPieceState(frame, ownerNodePath, key) {
|
|
469
|
+
this.assertFrame(frame);
|
|
470
|
+
const owner = this.sessionOwner(frame, ownerNodePath, key);
|
|
471
|
+
const row = this.db.get('piece_state', { run_id: this.runId, scope_id: frame.scope.id, node_path: owner, key });
|
|
472
|
+
return row ? JSON.parse(String(row.value_json)) : undefined;
|
|
473
|
+
}
|
|
474
|
+
writeScopedPieceState(frame, ownerNodePath, key, value) {
|
|
475
|
+
const owner = this.sessionOwner(frame, ownerNodePath, key);
|
|
476
|
+
this.db.transaction('scoped-session-updated', () => { this.assertFrame(frame); this.storePieceState(frame, key, value, owner); });
|
|
477
|
+
}
|
|
478
|
+
usage() {
|
|
479
|
+
const row = this.db.get('budget', { run_id: this.runId }), pending = this.reservationStatus().pendingInvocations > 0;
|
|
480
|
+
return { revision: this.db.revision, invocations: Number(this.db.sqlite.prepare("SELECT COUNT(*) AS count FROM invocations WHERE run_id=? AND status!='running'").get(this.runId).count),
|
|
481
|
+
costUsd: pending || row.cost_unknown ? null : Number(row.known_cost_usd), inputTokens: pending || row.input_unknown ? null : Number(row.input_tokens), outputTokens: pending || row.output_unknown ? null : Number(row.output_tokens),
|
|
482
|
+
knownCostUsd: Number(row.known_cost_usd), knownInputTokens: Number(row.input_tokens), knownOutputTokens: Number(row.output_tokens) };
|
|
483
|
+
}
|
|
484
|
+
/** Unreported spend keeps its reservation through recovery; bounds remain distinct from known usage. */
|
|
485
|
+
reservationStatus() {
|
|
486
|
+
const pending = Number(this.db.sqlite.prepare("SELECT COUNT(*) count FROM invocations WHERE run_id=? AND status='running'").get(this.runId).count);
|
|
487
|
+
const row = this.db.sqlite.prepare('SELECT COALESCE(SUM(max_tokens),0) tokens,COALESCE(SUM(max_cost_usd),0) cost,COUNT(*)-COUNT(max_tokens) unknown_tokens,COUNT(*)-COUNT(max_cost_usd) unknown_cost FROM reservations WHERE run_id=?').get(this.runId);
|
|
488
|
+
return { pendingInvocations: pending, knownTokens: Number(row.tokens), knownCostUsd: Number(row.cost), tokensUnknown: Number(row.unknown_tokens) > 0, costUnknown: Number(row.unknown_cost) > 0 };
|
|
489
|
+
}
|
|
490
|
+
activeDurationMs() {
|
|
491
|
+
const run = this.run();
|
|
492
|
+
return Number(run.active_duration_ms) + (run.active_started_at ? Math.max(0, Math.min(this.now(), this.lease.current()?.expiresAt ?? this.now()) - Date.parse(String(run.active_started_at))) : 0);
|
|
493
|
+
}
|
|
494
|
+
scopeSnapshot(scopeId = '*') {
|
|
495
|
+
const run = this.run();
|
|
496
|
+
return { runId: this.runId, scopeId, status: String(run.status), transitions: Number(run.transitions), usage: this.usage(),
|
|
497
|
+
candidate: run.candidate_json ? JSON.parse(String(run.candidate_json)) : null, verified: run.verified_json ? JSON.parse(String(run.verified_json)) : null,
|
|
498
|
+
consecutiveFailures: { revision: this.db.revision, count: Number(run.consecutive_failures) },
|
|
499
|
+
attempts: this.db.sqlite.prepare("SELECT a.* FROM attempts a JOIN visits v ON a.visit_id=v.visit_id WHERE a.run_id=? AND (?='*' OR a.scope_id=?) ORDER BY v.global_transition,a.attempt").all(this.runId, scopeId, scopeId).map(row => ({
|
|
500
|
+
frame: JSON.parse(String(row.frame_json)), status: String(row.status),
|
|
501
|
+
...(row.output_json ? { result: JSON.parse(String(row.output_json)) } : {}),
|
|
502
|
+
...(row.error_code ? { error: { code: String(row.error_code), message: String(row.error_message ?? '') } } : {}),
|
|
503
|
+
})) };
|
|
504
|
+
}
|
|
505
|
+
events(after = 0) {
|
|
506
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
507
|
+
throw new EngineError('invalid_arguments', 'Event cursor must be a non-negative integer');
|
|
508
|
+
return this.db.sqlite.prepare('SELECT payload_json FROM events WHERE run_id=? AND sequence>? AND inherited=0 ORDER BY sequence').all(this.runId, after).map(row => JSON.parse(String(row.payload_json)));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
2
|
+
import { BaseCheckpointSaver, type Checkpoint, type CheckpointMetadata, type CheckpointTuple, type CheckpointListOptions, type PendingWrite } from '@langchain/langgraph-checkpoint';
|
|
3
|
+
import { type DurableEngineEvent } from '../contracts.js';
|
|
4
|
+
import { RunDatabase } from './database.js';
|
|
5
|
+
import { RunLedger } from './ledger.js';
|
|
6
|
+
export interface SaverOptions {
|
|
7
|
+
/** Durable observers may fail without converting a committed effect into a retry. */
|
|
8
|
+
onEvents?: (events: DurableEngineEvent[]) => void | Promise<void>;
|
|
9
|
+
onObserverError?: (error: unknown) => void;
|
|
10
|
+
fault?: (phase: 'before-writes' | 'between-writes-ledger' | 'after-writes' | 'before-snapshot' | 'after-snapshot') => void;
|
|
11
|
+
}
|
|
12
|
+
/** LangGraph adapter. Terminal evidence and pending writes have exactly one commit owner. */
|
|
13
|
+
export declare class SqliteRunSaver extends BaseCheckpointSaver {
|
|
14
|
+
readonly database: RunDatabase;
|
|
15
|
+
private readonly ledger?;
|
|
16
|
+
private readonly options;
|
|
17
|
+
constructor(database: RunDatabase, ledger?: RunLedger | undefined, options?: SaverOptions);
|
|
18
|
+
private assertWriter;
|
|
19
|
+
private tuple;
|
|
20
|
+
getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined>;
|
|
21
|
+
list(config: RunnableConfig, options?: CheckpointListOptions): AsyncGenerator<CheckpointTuple>;
|
|
22
|
+
put(config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata): Promise<RunnableConfig>;
|
|
23
|
+
putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise<void>;
|
|
24
|
+
deleteThread(threadId: string): Promise<void>;
|
|
25
|
+
}
|