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,247 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { Command } from '@langchain/langgraph';
|
|
5
|
+
import { pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
|
|
6
|
+
import { coreRuntimeIdentity } from '../core-host.js';
|
|
7
|
+
import { sameRuntimeIdentity } from '../runtime-identity.js';
|
|
8
|
+
import { appendRunEvent, RunDatabase } from './checkpoint/database.js';
|
|
9
|
+
import { RunLedger } from './checkpoint/ledger.js';
|
|
10
|
+
import { RunLease } from './checkpoint/lease.js';
|
|
11
|
+
import { EngineError } from './contracts.js';
|
|
12
|
+
import { EngineEventStream } from './events.js';
|
|
13
|
+
import { initialDefinitionState } from './state.js';
|
|
14
|
+
import { preflightDefinition } from './preflight.js';
|
|
15
|
+
import { observeLedger, projectRunStatus } from './run-status.js';
|
|
16
|
+
import { ControlInbox } from './steering/inbox.js';
|
|
17
|
+
import { ledgerBudget } from './invocation-context.js';
|
|
18
|
+
import { composeDefinitionRuntime } from './composition.js';
|
|
19
|
+
import { createEngineTelemetry } from './otel.js';
|
|
20
|
+
import { describeDefinition } from './graph-description.js';
|
|
21
|
+
export const definitionRunDirectory = (context) => path.join(pipelineStateDirectory(context), 'agent-workflow');
|
|
22
|
+
/** Frozen request shared with the legacy runtime (contracts.md section 9); `workflow.engine` selects the resume engine. */
|
|
23
|
+
export const frozenRequestFile = (context) => path.join(pipelineStateDirectory(context), 'agent-runtime-request.json');
|
|
24
|
+
/** A request without the workflow block predates the contract and belongs to the legacy engine. */
|
|
25
|
+
export function frozenRequestEngine(request) {
|
|
26
|
+
const workflow = request && typeof request === 'object' ? request.workflow : undefined;
|
|
27
|
+
const engine = workflow && typeof workflow === 'object' ? workflow.engine : undefined;
|
|
28
|
+
return engine === 1 || engine === 2 ? engine : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Same no-overwrite discipline as the legacy writer: identical bytes are idempotent, anything else is another run. */
|
|
31
|
+
function freezeRequest(file, request) {
|
|
32
|
+
const serialized = JSON.stringify(request, null, 2) + '\n';
|
|
33
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(file, serialized, { flag: 'wx', mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'EEXIST')
|
|
39
|
+
throw error;
|
|
40
|
+
if (readFileSync(file, 'utf8') !== serialized)
|
|
41
|
+
throw new EngineError('run_exists', 'A different frozen request already uses this run ID; choose a new run ID');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Materializes the frozen request of an existing definition run (fork destinations) so hosts recognize a programmatic run. */
|
|
45
|
+
export async function freezeRunRequest(directory) {
|
|
46
|
+
const database = await RunDatabase.open(path.join(directory, 'run.sqlite'), { readOnly: true });
|
|
47
|
+
try {
|
|
48
|
+
const row = observeLedger(database).run(), request = JSON.parse(String(row.request_json));
|
|
49
|
+
const file = frozenRequestFile(request.context);
|
|
50
|
+
freezeRequest(file, { ...(request.change ? { change: request.change } : {}), config: request.config, runtimeIdentity: JSON.parse(String(row.runtime_identity_json)),
|
|
51
|
+
workflow: { id: String(row.workflow_id), version: String(row.definition_hash), source: 'definition', definitionHash: String(row.definition_hash), engine: 2 } });
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
database.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function createRun(input) {
|
|
59
|
+
const admitted = await preflightDefinition(input);
|
|
60
|
+
// The request is frozen before the database exists so a crash between the two leaves a
|
|
61
|
+
// request that a repeated identical `run` completes and a different one cannot reuse.
|
|
62
|
+
freezeRequest(frozenRequestFile(admitted.request.context), { ...(admitted.request.change ? { change: admitted.request.change } : {}), config: admitted.request.config, runtimeIdentity: coreRuntimeIdentity(),
|
|
63
|
+
workflow: { id: admitted.definition.id, version: admitted.definition.version, source: 'definition', definitionHash: admitted.definition.version, engine: 2 } });
|
|
64
|
+
const database = await RunDatabase.open(path.join(definitionRunDirectory(admitted.request.context), 'run.sqlite'), { create: true });
|
|
65
|
+
try {
|
|
66
|
+
RunLedger.initialize(database, { runId: admitted.request.context.runId, workflowId: admitted.definition.id,
|
|
67
|
+
definitionHash: admitted.definition.version, definition: admitted.definition, request: admitted.request,
|
|
68
|
+
runtimeIdentity: coreRuntimeIdentity(), source: 'definition', budget: admitted.budget });
|
|
69
|
+
const result = await execute(database, admitted, { ...input, context: admitted.request.context }, false);
|
|
70
|
+
return { ...projectRunStatus(observeLedger(database)), ...('error' in result ? { error: result.error } : {}) };
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
database.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function resumeRun(directory, options = {}) {
|
|
77
|
+
const database = await RunDatabase.open(path.join(directory, 'run.sqlite'));
|
|
78
|
+
try {
|
|
79
|
+
const view = observeLedger(database), row = view.run();
|
|
80
|
+
if (!sameRuntimeIdentity(JSON.parse(String(row.runtime_identity_json)), coreRuntimeIdentity()))
|
|
81
|
+
throw new EngineError('resume_incompatible', 'Resume requires the retained original Core package');
|
|
82
|
+
const request = JSON.parse(String(row.request_json));
|
|
83
|
+
if (options.answers && Object.keys(options.answers).length)
|
|
84
|
+
validateAnswers(view, options.answers);
|
|
85
|
+
if (options.context && JSON.stringify(options.context) !== JSON.stringify(request.context)) {
|
|
86
|
+
const { contentDigest } = await import('./canonical-json.js');
|
|
87
|
+
if (contentDigest(options.context) !== contentDigest(request.context))
|
|
88
|
+
throw new EngineError('scope_mismatch', 'Host context differs from the frozen run');
|
|
89
|
+
}
|
|
90
|
+
if (row.completion_json || row.status === 'cancelled') {
|
|
91
|
+
new EngineEventStream(cursor => view.events(cursor), options.onEvent ?? (() => { })).send(describeDefinition(view.runId, JSON.parse(String(row.definition_json))));
|
|
92
|
+
return projectRunStatus(view);
|
|
93
|
+
}
|
|
94
|
+
const admitted = await preflightDefinition({ ...request, definition: JSON.parse(String(row.definition_json)), registry: options.registry });
|
|
95
|
+
const result = await execute(database, admitted, options, true);
|
|
96
|
+
return { ...projectRunStatus(observeLedger(database)), ...('error' in result ? { error: result.error } : {}) };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
database.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Every effect runs under one lease and cancellation domain; the saver owns settlement. */
|
|
103
|
+
async function execute(database, admitted, options, resume) {
|
|
104
|
+
const runId = admitted.request.context.runId, lease = new RunLease(database, runId), token = lease.acquire(randomUUID());
|
|
105
|
+
const ledger = new RunLedger(database, token, { maxTransitions: admitted.definition.maxTransitions, failFast: admitted.definition.policies?.failFast });
|
|
106
|
+
const controller = new AbortController(), abort = () => controller.abort(options.signal?.reason ?? new EngineError('aborted', 'Execution cancelled'));
|
|
107
|
+
if (options.signal?.aborted)
|
|
108
|
+
abort();
|
|
109
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
110
|
+
const stopHeartbeat = lease.heartbeat(token, error => controller.abort(error)), inbox = new ControlInbox(database, runId);
|
|
111
|
+
const telemetry = createEngineTelemetry();
|
|
112
|
+
const stream = new EngineEventStream(cursor => ledger.events(cursor), options.onEvent ?? (() => { }), options.afterEvent, undefined, event => telemetry?.observe(event));
|
|
113
|
+
let runtime;
|
|
114
|
+
const config = { configurable: { thread_id: String(ledger.run().checkpoint_thread_id) }, durability: 'sync',
|
|
115
|
+
recursionLimit: admitted.definition.maxTransitions * 4 + 20 };
|
|
116
|
+
const controlTimer = setInterval(() => {
|
|
117
|
+
try {
|
|
118
|
+
if (inbox.cancellationRequested())
|
|
119
|
+
controller.abort(new EngineError('aborted', 'Cancellation requested'));
|
|
120
|
+
const limit = ledgerBudget(ledger).maxDurationMs;
|
|
121
|
+
if (limit !== undefined && ledger.activeDurationMs() >= limit)
|
|
122
|
+
controller.abort(new EngineError('timeout', 'Workflow active-duration budget exhausted'));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
controller.abort(error);
|
|
126
|
+
}
|
|
127
|
+
}, 250);
|
|
128
|
+
controlTimer.unref();
|
|
129
|
+
try {
|
|
130
|
+
ledger.settleAbandonedInvocations();
|
|
131
|
+
stream.send(describeDefinition(runId, admitted.definition));
|
|
132
|
+
runtime = await composeDefinitionRuntime(database, ledger, admitted, stream, controller.signal);
|
|
133
|
+
const { graph, saver, deps } = runtime;
|
|
134
|
+
deps.initializeCandidate();
|
|
135
|
+
if (options.answers && Object.keys(options.answers).length) {
|
|
136
|
+
validateAnswers(ledger, options.answers);
|
|
137
|
+
ledger.answerInterrupts(options.answers);
|
|
138
|
+
}
|
|
139
|
+
for (const target of options.recover ?? []) {
|
|
140
|
+
const rows = database.sqlite.prepare("SELECT a.attempt_id FROM attempts a JOIN visits v ON a.visit_id=v.visit_id JOIN steps s ON s.last_attempt_id=a.attempt_id WHERE a.run_id=? AND (a.attempt_id=? OR a.node_path=?) AND a.terminal_digest IS NULL AND a.status IN ('running','interrupted') AND v.effect='write'").all(runId, target, target);
|
|
141
|
+
if (rows.length !== 1)
|
|
142
|
+
throw new EngineError('recovery_ambiguous', 'Recovery must identify exactly one interrupted write attempt');
|
|
143
|
+
ledger.authorizeRecovery(String(rows[0].attempt_id));
|
|
144
|
+
}
|
|
145
|
+
if (resume)
|
|
146
|
+
database.transaction('workflow-resumed', () => {
|
|
147
|
+
lease.assert(token);
|
|
148
|
+
appendRunEvent(database, runId, 'workflow_resumed', {});
|
|
149
|
+
});
|
|
150
|
+
stream.flush();
|
|
151
|
+
const snapshot = await graph.getState(config, { subgraphs: true });
|
|
152
|
+
const activeInterrupts = new Set(snapshot.tasks.flatMap(task => task.interrupts).map(value => value.id));
|
|
153
|
+
const answers = Object.fromEntries(ledger.interrupts().filter(value => value.answer !== undefined && activeInterrupts.has(value.id)).map(value => [value.id, value.answer]));
|
|
154
|
+
const checkpoint = await saver.getTuple(config);
|
|
155
|
+
const hasInterrupts = snapshot.tasks.some(task => task.interrupts.length > 0);
|
|
156
|
+
if (hasInterrupts && !Object.keys(answers).length)
|
|
157
|
+
return projectRunStatus(ledger);
|
|
158
|
+
await graph.invoke(!checkpoint ? initialDefinitionState(admitted.request.change ? { changeId: admitted.request.change } : {}) : Object.keys(answers).length && hasInterrupts ? new Command({ resume: answers }) : null, { ...config, signal: controller.signal });
|
|
159
|
+
ledger.settlePause();
|
|
160
|
+
const row = ledger.run();
|
|
161
|
+
if (!row.completion_json && !ledger.pendingInterrupts().length && !['interrupted', 'failed', 'blocked'].includes(String(row.status))) {
|
|
162
|
+
throw new EngineError('completion_missing', 'Graph stopped without a durable completion or pending interruption');
|
|
163
|
+
}
|
|
164
|
+
return projectRunStatus(ledger);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
// A lost owner may observe, but can never settle effects after a newer epoch.
|
|
168
|
+
if (controller.signal.reason instanceof EngineError && controller.signal.reason.code === 'lease_lost')
|
|
169
|
+
throw controller.signal.reason;
|
|
170
|
+
const reason = controller.signal.aborted ? controller.signal.reason : error;
|
|
171
|
+
const code = reason instanceof EngineError || reason && typeof reason === 'object' && 'code' in reason ? String(reason.code) : 'execution_failed';
|
|
172
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
173
|
+
database.transaction('execution-stopped', () => {
|
|
174
|
+
lease.assert(token);
|
|
175
|
+
const row = ledger.run();
|
|
176
|
+
if (row.completion_json)
|
|
177
|
+
return;
|
|
178
|
+
const status = code === 'aborted' ? 'cancelled' : row.status === 'interrupted' || code === 'recover_required' ? 'interrupted' : 'failed';
|
|
179
|
+
database.put('runs', { ...row, status, active_duration_ms: ledger.activeDurationMs(), active_started_at: null, updated_at: new Date().toISOString() });
|
|
180
|
+
appendRunEvent(database, runId, status === 'cancelled' ? 'workflow_cancelled' : 'workflow_failed', { error: { code, message } });
|
|
181
|
+
});
|
|
182
|
+
if (inbox.cancellationRequested())
|
|
183
|
+
inbox.acknowledgeCancellation(token);
|
|
184
|
+
const status = projectRunStatus(ledger);
|
|
185
|
+
return { ...status, error: { code, message } };
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
clearInterval(controlTimer);
|
|
189
|
+
stopHeartbeat();
|
|
190
|
+
options.signal?.removeEventListener('abort', abort);
|
|
191
|
+
runtime?.close();
|
|
192
|
+
stream.flush();
|
|
193
|
+
lease.release(token);
|
|
194
|
+
await telemetry?.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function validateAnswers(ledger, answers) {
|
|
198
|
+
const known = new Map(ledger.interrupts().map(value => [value.id, value]));
|
|
199
|
+
for (const [id, value] of Object.entries(answers)) {
|
|
200
|
+
const request = known.get(id);
|
|
201
|
+
if (!request)
|
|
202
|
+
throw new EngineError('interrupt_not_found', 'Unknown interrupt ID');
|
|
203
|
+
const object = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
204
|
+
if (request.kind === 'question' ? typeof object.answer !== 'string' || !object.answer.trim() : object.approved !== true)
|
|
205
|
+
throw new EngineError('invalid_resume', 'Resume requires a matching nonempty answer or explicit approval');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export async function signalRun(directory, text, requestId) {
|
|
209
|
+
const database = await RunDatabase.open(path.join(directory, 'run.sqlite'));
|
|
210
|
+
try {
|
|
211
|
+
return new ControlInbox(database, observeLedger(database).runId).append(text, { requestId });
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
database.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function cancelRun(directory, requestId) {
|
|
218
|
+
const database = await RunDatabase.open(path.join(directory, 'run.sqlite'));
|
|
219
|
+
try {
|
|
220
|
+
const runId = observeLedger(database).runId, inbox = new ControlInbox(database, runId), accepted = inbox.cancel(requestId);
|
|
221
|
+
const lease = new RunLease(database, runId);
|
|
222
|
+
if ((lease.current()?.expiresAt ?? 0) > Date.now())
|
|
223
|
+
return accepted;
|
|
224
|
+
const token = lease.acquire(randomUUID());
|
|
225
|
+
try {
|
|
226
|
+
const ledger = new RunLedger(database, token, { maxTransitions: 1 });
|
|
227
|
+
database.transaction('workflow-cancelled', () => {
|
|
228
|
+
lease.assert(token);
|
|
229
|
+
const run = ledger.run();
|
|
230
|
+
// An idempotent retry or a run finishing between inbox admission and
|
|
231
|
+
// lease acquisition must not rewrite terminal truth or duplicate it.
|
|
232
|
+
if (run.status === 'cancelled' || run.status === 'succeeded' || run.completion_json)
|
|
233
|
+
return;
|
|
234
|
+
database.put('runs', { ...run, status: 'cancelled', active_duration_ms: ledger.activeDurationMs(), active_started_at: null });
|
|
235
|
+
appendRunEvent(database, runId, 'workflow_cancelled', {});
|
|
236
|
+
});
|
|
237
|
+
inbox.acknowledgeCancellation(token);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
lease.release(token);
|
|
241
|
+
}
|
|
242
|
+
return accepted;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
database.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { EphemeralValue } from '@langchain/langgraph/channels';
|
|
2
|
+
import { type BranchState, type CandidateState, type CoreDefinitionState, type EngineAnswer, type EngineUsage, type ExecutionScope, type HistoryEntry, type JsonValue, type TerminalCommit, type VerifiedState } from './contracts.js';
|
|
3
|
+
export declare function emptyUsage(): EngineUsage;
|
|
4
|
+
export declare function mergeRevision<T extends {
|
|
5
|
+
revision: number;
|
|
6
|
+
}>(previous: T, update: T): T;
|
|
7
|
+
type OrderedRecord = {
|
|
8
|
+
id: string;
|
|
9
|
+
transition: number;
|
|
10
|
+
attempt: number;
|
|
11
|
+
ordinal: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function mergeOrdered<T extends OrderedRecord>(previous: T[], update: T[]): T[];
|
|
14
|
+
export declare function mergeHistory(previous: HistoryEntry[], update: HistoryEntry[], maxChars: number): HistoryEntry[];
|
|
15
|
+
/** Private commit markers reach each task's saver writes and expire after a superstep. */
|
|
16
|
+
export declare function definitionStateSchema(historyMaxChars?: number, scope?: ExecutionScope | null): import("@langchain/langgraph").AnnotationRoot<{
|
|
17
|
+
$outputs: import("@langchain/langgraph").BaseChannel<Record<string, JsonValue>, Record<string, JsonValue> | import("@langchain/langgraph").OverwriteValue<Record<string, JsonValue>>, unknown>;
|
|
18
|
+
$vars: import("@langchain/langgraph").BaseChannel<Record<string, JsonValue>, Record<string, JsonValue> | import("@langchain/langgraph").OverwriteValue<Record<string, JsonValue>>, unknown>;
|
|
19
|
+
$history: import("@langchain/langgraph").BaseChannel<HistoryEntry[], HistoryEntry[] | import("@langchain/langgraph").OverwriteValue<HistoryEntry[]>, unknown>;
|
|
20
|
+
$sessions: import("@langchain/langgraph").BaseChannel<Record<string, {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
identity: string;
|
|
23
|
+
}>, Record<string, {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
identity: string;
|
|
26
|
+
}> | import("@langchain/langgraph").OverwriteValue<Record<string, {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
identity: string;
|
|
29
|
+
}>>, unknown>;
|
|
30
|
+
$usage: import("@langchain/langgraph").BaseChannel<EngineUsage, EngineUsage | import("@langchain/langgraph").OverwriteValue<EngineUsage>, unknown>;
|
|
31
|
+
$attempts: import("@langchain/langgraph").BaseChannel<Record<string, number>, Record<string, number> | import("@langchain/langgraph").OverwriteValue<Record<string, number>>, unknown>;
|
|
32
|
+
$consecutiveFailures: import("@langchain/langgraph").BaseChannel<{
|
|
33
|
+
revision: number;
|
|
34
|
+
count: number;
|
|
35
|
+
}, {
|
|
36
|
+
revision: number;
|
|
37
|
+
count: number;
|
|
38
|
+
} | import("@langchain/langgraph").OverwriteValue<{
|
|
39
|
+
revision: number;
|
|
40
|
+
count: number;
|
|
41
|
+
}>, unknown>;
|
|
42
|
+
$candidate: import("@langchain/langgraph").BaseChannel<CandidateState | null, CandidateState | import("@langchain/langgraph").OverwriteValue<CandidateState | null> | null, unknown>;
|
|
43
|
+
$verified: import("@langchain/langgraph").BaseChannel<VerifiedState | null, VerifiedState | import("@langchain/langgraph").OverwriteValue<VerifiedState | null> | null, unknown>;
|
|
44
|
+
$answers: import("@langchain/langgraph").BaseChannel<EngineAnswer[], EngineAnswer[] | import("@langchain/langgraph").OverwriteValue<EngineAnswer[]>, unknown>;
|
|
45
|
+
$branches: import("@langchain/langgraph").BaseChannel<Record<string, BranchState>, Record<string, BranchState> | import("@langchain/langgraph").OverwriteValue<Record<string, BranchState>>, unknown>;
|
|
46
|
+
$maps: import("@langchain/langgraph").BaseChannel<Record<string, import("./contracts.js").MapPlan>, Record<string, import("./contracts.js").MapPlan> | import("@langchain/langgraph").OverwriteValue<Record<string, import("./contracts.js").MapPlan>>, unknown>;
|
|
47
|
+
$transitions: import("@langchain/langgraph").BaseChannel<number, number | import("@langchain/langgraph").OverwriteValue<number>, unknown>;
|
|
48
|
+
$lastOutcome: import("@langchain/langgraph").BaseChannel<Record<string, string>, Record<string, string> | import("@langchain/langgraph").OverwriteValue<Record<string, string>>, unknown>;
|
|
49
|
+
$item: import("@langchain/langgraph").BaseChannel<{
|
|
50
|
+
index: number;
|
|
51
|
+
value: JsonValue;
|
|
52
|
+
} | null, {
|
|
53
|
+
index: number;
|
|
54
|
+
value: JsonValue;
|
|
55
|
+
} | import("@langchain/langgraph").OverwriteValue<{
|
|
56
|
+
index: number;
|
|
57
|
+
value: JsonValue;
|
|
58
|
+
} | null> | null, unknown>;
|
|
59
|
+
$scope: import("@langchain/langgraph").BaseChannel<ExecutionScope, ExecutionScope | import("@langchain/langgraph").OverwriteValue<ExecutionScope>, unknown>;
|
|
60
|
+
$exit: import("@langchain/langgraph").BaseChannel<import("./contracts.js").ComponentExit | null, import("./contracts.js").ComponentExit | import("@langchain/langgraph").OverwriteValue<import("./contracts.js").ComponentExit | null> | null, unknown>;
|
|
61
|
+
$commit: EphemeralValue<TerminalCommit>;
|
|
62
|
+
}>;
|
|
63
|
+
export declare function initialDefinitionState(vars?: Record<string, JsonValue>, scope?: ExecutionScope): CoreDefinitionState;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Annotation } from '@langchain/langgraph';
|
|
2
|
+
import { EphemeralValue } from '@langchain/langgraph/channels';
|
|
3
|
+
import { canonicalJson } from './canonical-json.js';
|
|
4
|
+
import { EngineError } from './contracts.js';
|
|
5
|
+
export function emptyUsage() {
|
|
6
|
+
return { revision: 0, invocations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, knownCostUsd: 0, knownInputTokens: 0, knownOutputTokens: 0 };
|
|
7
|
+
}
|
|
8
|
+
function mergeKeys(previous, update) {
|
|
9
|
+
return Object.assign(Object.create(null), previous, update);
|
|
10
|
+
}
|
|
11
|
+
export function mergeRevision(previous, update) {
|
|
12
|
+
if (update.revision < previous.revision)
|
|
13
|
+
return previous;
|
|
14
|
+
if (update.revision === previous.revision && canonicalJson(update) !== canonicalJson(previous))
|
|
15
|
+
throw new EngineError('state_revision_conflict', 'Two different values have the same durable revision');
|
|
16
|
+
return update;
|
|
17
|
+
}
|
|
18
|
+
export function mergeOrdered(previous, update) {
|
|
19
|
+
const entries = new Map(previous.map(value => [value.id, value]));
|
|
20
|
+
for (const value of update) {
|
|
21
|
+
const existing = entries.get(value.id);
|
|
22
|
+
if (existing && canonicalJson(existing) !== canonicalJson(value))
|
|
23
|
+
throw new EngineError('state_record_conflict', `Record ${value.id} has conflicting content`);
|
|
24
|
+
entries.set(value.id, value);
|
|
25
|
+
}
|
|
26
|
+
return [...entries.values()].sort((a, b) => a.transition - b.transition || a.attempt - b.attempt || a.ordinal - b.ordinal || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
27
|
+
}
|
|
28
|
+
export function mergeHistory(previous, update, maxChars) {
|
|
29
|
+
const bounded = update.flatMap(entry => {
|
|
30
|
+
if (JSON.stringify(entry).length <= maxChars - 2)
|
|
31
|
+
return [entry];
|
|
32
|
+
let low = 0;
|
|
33
|
+
let high = Math.min(entry.text.length, maxChars);
|
|
34
|
+
while (low < high) {
|
|
35
|
+
const middle = Math.ceil((low + high) / 2);
|
|
36
|
+
if (JSON.stringify({ ...entry, text: entry.text.slice(0, middle), truncated: true }).length <= maxChars - 2)
|
|
37
|
+
low = middle;
|
|
38
|
+
else
|
|
39
|
+
high = middle - 1;
|
|
40
|
+
}
|
|
41
|
+
const trimmed = { ...entry, text: entry.text.slice(0, low).replace(/[\uD800-\uDBFF]$/, ''), truncated: true };
|
|
42
|
+
return JSON.stringify(trimmed).length <= maxChars - 2 ? [trimmed] : [];
|
|
43
|
+
});
|
|
44
|
+
const entries = mergeOrdered(previous, bounded);
|
|
45
|
+
let total = 2 + Math.max(0, entries.length - 1) + entries.reduce((sum, entry) => sum + JSON.stringify(entry).length, 0);
|
|
46
|
+
while (entries.length && total > maxChars)
|
|
47
|
+
total -= JSON.stringify(entries.shift()).length + (entries.length ? 1 : 0);
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
function mergeBranches(previous, update) {
|
|
51
|
+
const merged = mergeKeys(previous, {});
|
|
52
|
+
for (const [id, branch] of Object.entries(update)) {
|
|
53
|
+
const existing = merged[id];
|
|
54
|
+
if (existing?.visitId !== branch.visitId && existing?.visitId && branch.visitId) {
|
|
55
|
+
if ((branch.transition ?? 0) <= (existing.transition ?? 0))
|
|
56
|
+
continue;
|
|
57
|
+
merged[id] = { ...branch, done: branch.results.length };
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (existing && existing.total !== branch.total)
|
|
61
|
+
throw new EngineError('state_record_conflict', `Branch count changed for ${id}`);
|
|
62
|
+
const results = mergeOrdered(existing?.results ?? [], branch.results);
|
|
63
|
+
merged[id] = { ...branch, results, done: results.length };
|
|
64
|
+
}
|
|
65
|
+
return merged;
|
|
66
|
+
}
|
|
67
|
+
/** Private commit markers reach each task's saver writes and expire after a superstep. */
|
|
68
|
+
export function definitionStateSchema(historyMaxChars = 1500, scope = { id: 'root', nodePathPrefix: '' }) {
|
|
69
|
+
return Annotation.Root({
|
|
70
|
+
$outputs: Annotation({ reducer: mergeKeys, default: () => ({}) }),
|
|
71
|
+
$vars: Annotation({ reducer: mergeKeys, default: () => ({}) }),
|
|
72
|
+
$history: Annotation({ reducer: (a, b) => mergeHistory(a, b, historyMaxChars), default: () => [] }),
|
|
73
|
+
$sessions: Annotation({ reducer: mergeKeys, default: () => ({}) }),
|
|
74
|
+
$usage: Annotation({ reducer: mergeRevision, default: emptyUsage }),
|
|
75
|
+
$attempts: Annotation({ reducer: (a, b) => { const result = mergeKeys(a, {}); for (const [id, count] of Object.entries(b))
|
|
76
|
+
result[id] = Math.max(result[id] ?? 0, count); return result; }, default: () => ({}) }),
|
|
77
|
+
$consecutiveFailures: Annotation({ reducer: mergeRevision, default: () => ({ revision: 0, count: 0 }) }),
|
|
78
|
+
$candidate: Annotation({ reducer: (a, b) => b === null ? a : a === null ? b : mergeRevision(a, b), default: () => null }),
|
|
79
|
+
// Only the exclusive writer emits this channel. Read branches never publish stale receipts.
|
|
80
|
+
$verified: Annotation({ reducer: (a, b) => b === null ? null : a === null ? b : mergeRevision(a, b), default: () => null }),
|
|
81
|
+
$answers: Annotation({ reducer: mergeOrdered, default: () => [] }),
|
|
82
|
+
$branches: Annotation({ reducer: mergeBranches, default: () => ({}) }),
|
|
83
|
+
$maps: Annotation({ reducer: mergeKeys, default: () => ({}) }),
|
|
84
|
+
$transitions: Annotation({ reducer: Math.max, default: () => 0 }),
|
|
85
|
+
$lastOutcome: Annotation({ reducer: mergeKeys, default: () => ({}) }),
|
|
86
|
+
$item: Annotation({ reducer: (_a, b) => b, default: () => null }),
|
|
87
|
+
$scope: Annotation({ reducer: (a, b) => { if (!a.id)
|
|
88
|
+
return b; if (canonicalJson(a) !== canonicalJson(b))
|
|
89
|
+
throw new EngineError('state_scope_conflict', 'Execution scope is immutable'); return a; }, default: () => structuredClone(scope ?? { id: '', nodePathPrefix: '' }) }),
|
|
90
|
+
$exit: Annotation({ reducer: (a, b) => b && (!a || b.transition >= a.transition) ? b : a, default: () => null }),
|
|
91
|
+
$commit: new EphemeralValue(false),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function initialDefinitionState(vars = {}, scope = { id: 'root', nodePathPrefix: '' }) {
|
|
95
|
+
return { $outputs: {}, $vars: structuredClone(vars), $history: [], $sessions: {}, $usage: emptyUsage(), $attempts: {},
|
|
96
|
+
$consecutiveFailures: { revision: 0, count: 0 }, $candidate: null, $verified: null, $answers: [], $branches: {}, $maps: {},
|
|
97
|
+
$transitions: 0, $lastOutcome: {}, $item: null, $scope: structuredClone(scope), $exit: null };
|
|
98
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AttemptFrame, type ExecutionLease } from '../contracts.js';
|
|
2
|
+
import { RunDatabase } from '../checkpoint/database.js';
|
|
3
|
+
export declare const MAX_STEERING_CHARACTERS = 20000;
|
|
4
|
+
export declare const MAX_STEERING_BYTES = 80000;
|
|
5
|
+
export declare const MAX_PENDING_STEERING = 128;
|
|
6
|
+
export declare const MAX_PENDING_STEERING_BYTES: number;
|
|
7
|
+
export interface SteeringMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
text: string;
|
|
10
|
+
sender: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
consumedByAttemptId?: string;
|
|
13
|
+
consumedAt?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SteeringReceipt {
|
|
16
|
+
id: string;
|
|
17
|
+
acceptedAt: string;
|
|
18
|
+
preview: string;
|
|
19
|
+
length: number;
|
|
20
|
+
status: 'pending' | 'consumed';
|
|
21
|
+
consumedAttemptId?: string;
|
|
22
|
+
consumedAt?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A second process can append controls without acquiring or replacing the execution lease. */
|
|
25
|
+
export declare class ControlInbox {
|
|
26
|
+
readonly database: RunDatabase;
|
|
27
|
+
readonly runId: string;
|
|
28
|
+
constructor(database: RunDatabase, runId: string);
|
|
29
|
+
append(text: string, options?: {
|
|
30
|
+
requestId?: string;
|
|
31
|
+
sender?: string;
|
|
32
|
+
}): SteeringMessage;
|
|
33
|
+
cancel(requestId?: string): {
|
|
34
|
+
requestId: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
};
|
|
37
|
+
private appendControl;
|
|
38
|
+
pending(): SteeringMessage[];
|
|
39
|
+
/** Bounded read-only projection. Counts cover the whole inbox, previews never expose full prompts. */
|
|
40
|
+
status(): {
|
|
41
|
+
receipts: SteeringReceipt[];
|
|
42
|
+
pending: number;
|
|
43
|
+
consumed: number;
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
consumptionReported: true;
|
|
46
|
+
};
|
|
47
|
+
assigned(frame: AttemptFrame): SteeringMessage[];
|
|
48
|
+
/** Called inside attempt admission, after the attempt row exists and the lease was fenced. */
|
|
49
|
+
claimForAttempt(frame: AttemptFrame): SteeringMessage[];
|
|
50
|
+
cancellationRequested(): boolean;
|
|
51
|
+
acknowledgeCancellation(token: ExecutionLease): void;
|
|
52
|
+
}
|
|
53
|
+
/** Operator text is delimited and cannot replace frozen acceptance obligations/instructions. */
|
|
54
|
+
export declare function renderOperatorSteering(messages: readonly SteeringMessage[]): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from '../canonical-json.js';
|
|
3
|
+
import { EngineError } from '../contracts.js';
|
|
4
|
+
import { appendRunEvent } from '../checkpoint/database.js';
|
|
5
|
+
import { RunLease } from '../checkpoint/lease.js';
|
|
6
|
+
export const MAX_STEERING_CHARACTERS = 20_000;
|
|
7
|
+
export const MAX_STEERING_BYTES = 80_000;
|
|
8
|
+
export const MAX_PENDING_STEERING = 128;
|
|
9
|
+
export const MAX_PENDING_STEERING_BYTES = 512 * 1024;
|
|
10
|
+
const project = (row) => {
|
|
11
|
+
const value = JSON.parse(String(row.payload_json));
|
|
12
|
+
return { id: String(row.request_id), ...value, createdAt: String(row.created_at),
|
|
13
|
+
...(row.consumed_by_attempt_id ? { consumedByAttemptId: String(row.consumed_by_attempt_id) } : {}), ...(row.consumed_at ? { consumedAt: String(row.consumed_at) } : {}) };
|
|
14
|
+
};
|
|
15
|
+
/** A second process can append controls without acquiring or replacing the execution lease. */
|
|
16
|
+
export class ControlInbox {
|
|
17
|
+
database;
|
|
18
|
+
runId;
|
|
19
|
+
constructor(database, runId) {
|
|
20
|
+
this.database = database;
|
|
21
|
+
this.runId = runId;
|
|
22
|
+
}
|
|
23
|
+
append(text, options = {}) {
|
|
24
|
+
if (typeof text !== 'string' || !text.trim() || text.length > MAX_STEERING_CHARACTERS || Buffer.byteLength(text, 'utf8') > MAX_STEERING_BYTES)
|
|
25
|
+
throw new EngineError('invalid_arguments', `Steering must contain 1–${MAX_STEERING_CHARACTERS} characters, within ${MAX_STEERING_BYTES} UTF-8 bytes`);
|
|
26
|
+
const sender = options.sender ?? 'operator';
|
|
27
|
+
if (!sender || sender.length > 128)
|
|
28
|
+
throw new EngineError('invalid_arguments', 'Steering sender must contain 1–128 characters');
|
|
29
|
+
const row = this.appendControl('steer', { text, sender }, options.requestId ?? randomUUID());
|
|
30
|
+
return project(row);
|
|
31
|
+
}
|
|
32
|
+
cancel(requestId = randomUUID()) {
|
|
33
|
+
const row = this.appendControl('cancel', {}, requestId);
|
|
34
|
+
return { requestId: String(row.request_id), createdAt: String(row.created_at) };
|
|
35
|
+
}
|
|
36
|
+
appendControl(kind, payload, requestId) {
|
|
37
|
+
if (!requestId || requestId.length > 256)
|
|
38
|
+
throw new EngineError('invalid_arguments', 'Control request ID must contain 1–256 characters');
|
|
39
|
+
const encoded = canonicalJson(payload);
|
|
40
|
+
return this.database.transaction('control-appended', () => {
|
|
41
|
+
const run = this.database.get('runs', { run_id: this.runId });
|
|
42
|
+
if (!run)
|
|
43
|
+
throw new EngineError('run_not_found', 'Run does not exist');
|
|
44
|
+
const existing = this.database.get('control_inbox', { request_id: requestId });
|
|
45
|
+
if (existing) {
|
|
46
|
+
if (existing.run_id !== this.runId || existing.kind !== kind || existing.payload_json !== encoded)
|
|
47
|
+
throw new EngineError('control_conflict', 'An idempotency key cannot change its control payload');
|
|
48
|
+
return existing;
|
|
49
|
+
}
|
|
50
|
+
if (run.completion_json || run.status === 'succeeded' || run.status === 'cancelled')
|
|
51
|
+
throw new EngineError('run_terminal', 'Completed runs cannot receive new controls');
|
|
52
|
+
if (kind === 'steer') {
|
|
53
|
+
const pending = this.database.sqlite.prepare("SELECT COUNT(*) count,COALESCE(SUM(length(CAST(payload_json AS BLOB))),0) bytes FROM control_inbox WHERE run_id=? AND kind='steer' AND consumed_at IS NULL").get(this.runId);
|
|
54
|
+
if (Number(pending.count) >= MAX_PENDING_STEERING || Number(pending.bytes) + Buffer.byteLength(encoded) > MAX_PENDING_STEERING_BYTES)
|
|
55
|
+
throw new EngineError('inbox_full', 'Pending operator steering reached its bounded capacity');
|
|
56
|
+
}
|
|
57
|
+
const at = new Date().toISOString();
|
|
58
|
+
this.database.put('control_inbox', { request_id: requestId, run_id: this.runId, kind, payload_json: encoded, created_at: at, received_sequence: run.next_event_sequence });
|
|
59
|
+
appendRunEvent(this.database, this.runId, kind === 'steer' ? 'steering_received' : 'cancellation_requested', { requestId }, {}, at);
|
|
60
|
+
return this.database.get('control_inbox', { request_id: requestId });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
pending() {
|
|
64
|
+
return this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='steer' AND consumed_at IS NULL ORDER BY received_sequence,request_id").all(this.runId).map(project);
|
|
65
|
+
}
|
|
66
|
+
/** Bounded read-only projection. Counts cover the whole inbox, previews never expose full prompts. */
|
|
67
|
+
status() {
|
|
68
|
+
const counts = this.database.sqlite.prepare("SELECT COUNT(*) total,COALESCE(SUM(consumed_at IS NULL),0) pending FROM control_inbox WHERE run_id=? AND kind='steer'").get(this.runId);
|
|
69
|
+
const rows = this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='steer' ORDER BY consumed_at IS NULL DESC,received_sequence DESC,request_id DESC LIMIT 512").all(this.runId);
|
|
70
|
+
const receipts = rows.map(row => {
|
|
71
|
+
const message = project(row);
|
|
72
|
+
return { id: message.id, acceptedAt: message.createdAt, preview: message.text.slice(0, 240), length: message.text.length,
|
|
73
|
+
status: message.consumedAt ? 'consumed' : 'pending',
|
|
74
|
+
...(message.consumedByAttemptId ? { consumedAttemptId: message.consumedByAttemptId } : {}),
|
|
75
|
+
...(message.consumedAt ? { consumedAt: message.consumedAt } : {}) };
|
|
76
|
+
});
|
|
77
|
+
return { receipts, pending: Number(counts.pending), consumed: Number(counts.total) - Number(counts.pending),
|
|
78
|
+
truncated: Number(counts.total) > rows.length, consumptionReported: true };
|
|
79
|
+
}
|
|
80
|
+
assigned(frame) {
|
|
81
|
+
return this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='steer' AND consumed_by_attempt_id=? ORDER BY received_sequence,request_id").all(this.runId, frame.attemptId).map(project);
|
|
82
|
+
}
|
|
83
|
+
/** Called inside attempt admission, after the attempt row exists and the lease was fenced. */
|
|
84
|
+
claimForAttempt(frame) {
|
|
85
|
+
void this.database.transactionRevision;
|
|
86
|
+
const attempt = this.database.get('attempts', { attempt_id: frame.attemptId });
|
|
87
|
+
if (!attempt || attempt.run_id !== this.runId || attempt.status !== 'running' || attempt.lease_epoch !== frame.leaseEpoch)
|
|
88
|
+
throw new EngineError('attempt_mismatch', 'Steering can only be claimed by the admitted attempt');
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
for (const row of this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='steer' AND consumed_at IS NULL ORDER BY received_sequence,request_id").all(this.runId)) {
|
|
91
|
+
this.database.put('control_inbox', { ...row, consumed_by_attempt_id: frame.attemptId, consumed_at: now });
|
|
92
|
+
}
|
|
93
|
+
return this.assigned(frame);
|
|
94
|
+
}
|
|
95
|
+
cancellationRequested() {
|
|
96
|
+
return !!this.database.sqlite.prepare("SELECT 1 FROM control_inbox WHERE run_id=? AND kind='cancel' AND consumed_at IS NULL").get(this.runId);
|
|
97
|
+
}
|
|
98
|
+
acknowledgeCancellation(token) {
|
|
99
|
+
this.database.transaction('cancellation-consumed', () => {
|
|
100
|
+
new RunLease(this.database, this.runId).assert(token);
|
|
101
|
+
for (const row of this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='cancel' AND consumed_at IS NULL").all(this.runId)) {
|
|
102
|
+
this.database.put('control_inbox', { ...row, consumed_at: new Date().toISOString() });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Operator text is delimited and cannot replace frozen acceptance obligations/instructions. */
|
|
108
|
+
export function renderOperatorSteering(messages) {
|
|
109
|
+
if (!messages.length)
|
|
110
|
+
return '';
|
|
111
|
+
return '\n\n## Operator steering\n\nApply these operator messages within the frozen workflow and its acceptance requirements.\n\n' +
|
|
112
|
+
messages.map(message => `### Message ${JSON.stringify(message.id)} from ${JSON.stringify(message.sender)}\n${JSON.stringify(message.text)}`).join('\n\n');
|
|
113
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** SQLite holds the construction lock across awaits and releases it on process
|
|
2
|
+
* death. The separate, flushed manifest survives that rollback. Directories are
|
|
3
|
+
* recorded by inode before their atomic rename, so recovery never removes a
|
|
4
|
+
* replacement directory or a child already published by Core.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ForkAllocation {
|
|
7
|
+
private readonly lock;
|
|
8
|
+
private readonly directory;
|
|
9
|
+
private readonly parents;
|
|
10
|
+
private manifest;
|
|
11
|
+
private constructor();
|
|
12
|
+
static open(target: string, artifactRoot: string, requestId: string | undefined, digest: string): Promise<ForkAllocation>;
|
|
13
|
+
/** Call only after ruling out a published child. No request ID means no retry. */
|
|
14
|
+
recover(): void;
|
|
15
|
+
/** Reserve an empty directory before any journal/artifact writes occur. */
|
|
16
|
+
reserve(target: string): void;
|
|
17
|
+
cleanup(): void;
|
|
18
|
+
close(): void;
|
|
19
|
+
private validate;
|
|
20
|
+
private save;
|
|
21
|
+
}
|