mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(runner): resume governed live attempts durably
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
928160ce23
commit
3b27ea5c36
10 files changed
+1189
-35
No files matched your search
@@ -26,18 +26,42 @@ the separate skillless, network-disabled workspace permission profile.
|
||||
|
||||
## Stable session API
|
||||
|
||||
Import the package root and construct `CapabilityLiveSessionService`:
|
||||
Production workers import the live entrypoint and bind a durable store to the
|
||||
attempt's immutable authority tuple:
|
||||
|
||||
```ts
|
||||
import { CapabilityLiveSessionService } from "@paperclipai/paperclip-runner/testing";
|
||||
import {
|
||||
CapabilityLiveSessionService,
|
||||
DurableCapabilityLiveSessionStore,
|
||||
} from "@paperclipai/paperclip-runner/live";
|
||||
|
||||
const binding = { sessionId, runId, companyId, actorId, taskId };
|
||||
const store = new DurableCapabilityLiveSessionStore({ directory, binding });
|
||||
const service = new CapabilityLiveSessionService({ store });
|
||||
const session = await service.create({ workingDirectory });
|
||||
const session = await service.create({ ...binding, attemptId, workingDirectory });
|
||||
const turn = await session.sendMessage("Read the mock task and report progress.");
|
||||
await session.interrupt(); // only when a turn is active
|
||||
await service.stop(session.id);
|
||||
```
|
||||
|
||||
After a worker terminates, a new worker must construct a new service and use the
|
||||
production resume entrypoint. It must not call `create` again:
|
||||
|
||||
```ts
|
||||
const resumed = await new CapabilityLiveSessionService({ store }).resume({
|
||||
sessionId,
|
||||
attemptId: resumeAttemptId,
|
||||
resumeOf: killedAttemptId,
|
||||
});
|
||||
await resumed.reconcileActiveTurn();
|
||||
```
|
||||
|
||||
`resume` first commits the killed attempt as `terminated` and the successor as
|
||||
`running`. Only then does it start runnerd, read the checkpointed provider
|
||||
thread, and resume that exact thread. A missing or corrupt checkpoint, authority
|
||||
binding mismatch, attempt-lineage mismatch, or provider thread/session drift
|
||||
fails closed. The killed attempt and its usage remain immutable.
|
||||
|
||||
The handoff surface for later tracks is:
|
||||
|
||||
- `create(input)` starts runnerd, Codex, one mock run, and one dynamic-tool thread.
|
||||
@@ -48,14 +72,23 @@ The handoff surface for later tracks is:
|
||||
Codex app-server, then reads and resumes the persisted provider thread.
|
||||
- `restore(sessionId)` recreates mock state, transcript, authority, authorization
|
||||
records, pending interactions, and the provider thread from a stored snapshot.
|
||||
- `resume({ sessionId, attemptId, resumeOf })` is the cross-worker production
|
||||
path and records distinct linked attempts before provider recovery.
|
||||
- `recordUsage(receipt)` durably commits an attempt-bound, exactly-once provider
|
||||
response receipt before the caller acknowledges that response. Reusing a
|
||||
receipt with different contents fails closed.
|
||||
- `reconcileActiveTurn()` interrupts and records the terminal fact for a turn
|
||||
that was active in the checkpoint when its worker terminated.
|
||||
- `interrupt(reason)` cancels only the active turn and retains session authority.
|
||||
- `service.stop(sessionId)` clears authority and reaps the process group;
|
||||
`reset` also deletes the old snapshot and restores the original clean mock
|
||||
seed under new run/session authority.
|
||||
|
||||
Supply a durable implementation of `CapabilityLiveSessionStore` for refresh and
|
||||
server-restart survival. The included in-memory store is intended for tests and
|
||||
single-process consumers.
|
||||
`DurableCapabilityLiveSessionStore` writes a checksummed, revisioned checkpoint
|
||||
with atomic rename plus file and directory fsync. It persists provider identity,
|
||||
mock state and semantic idempotency receipts, attempt lineage, active and
|
||||
terminal turn facts, and the usage ledger. The included in-memory store remains
|
||||
limited to tests and single-process consumers.
|
||||
|
||||
Every snapshot includes bounded transcript/evidence, serialized mock state,
|
||||
semantic authorization records, runner/Codex PIDs and exit state, and explicit
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"types": "./dist/evals/index.d.ts",
|
||||
"import": "./dist/evals/index.js"
|
||||
},
|
||||
"./live": {
|
||||
"types": "./dist/live/index.d.ts",
|
||||
"import": "./dist/live/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser/index.d.ts",
|
||||
"import": "./dist/browser/index.js"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, ExitCode, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use paperclip_runner_core::durable::{
|
||||
@@ -90,8 +92,8 @@ fn run_codex_app_server_proxy(args: &[String]) -> Result<(), LocalRunnerError> {
|
||||
command
|
||||
.args(&codex_args)
|
||||
.env_clear()
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
for key in [
|
||||
"ALL_PROXY",
|
||||
@@ -119,6 +121,40 @@ fn run_codex_app_server_proxy(args: &[String]) -> Result<(), LocalRunnerError> {
|
||||
let mut child = command.spawn().map_err(|error| {
|
||||
LocalRunnerError::invalid(format!("failed to start Codex app-server: {error}"))
|
||||
})?;
|
||||
let mut child_stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| LocalRunnerError::invalid("failed to capture Codex app-server stdin"))?;
|
||||
let mut child_stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| LocalRunnerError::invalid("failed to capture Codex app-server stdout"))?;
|
||||
thread::spawn(move || {
|
||||
let mut stdin = io::stdin().lock();
|
||||
let mut line = Vec::new();
|
||||
while stdin.read_until(b'\n', &mut line).unwrap_or(0) > 0 {
|
||||
if child_stdin.write_all(&line).is_err() || child_stdin.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
let stdout_relay = thread::spawn(move || {
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut child_stdout = BufReader::new(&mut child_stdout);
|
||||
let mut line = Vec::new();
|
||||
let mut relayed = 0;
|
||||
loop {
|
||||
let bytes = child_stdout.read_until(b'\n', &mut line)?;
|
||||
if bytes == 0 {
|
||||
return Ok::<u64, io::Error>(relayed);
|
||||
}
|
||||
stdout.write_all(&line)?;
|
||||
stdout.flush()?;
|
||||
relayed += bytes as u64;
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
eprintln!(
|
||||
"paperclip-runnerd: native codex proxy started runner_pid={} codex_pid={}",
|
||||
std::process::id(),
|
||||
@@ -127,6 +163,12 @@ fn run_codex_app_server_proxy(args: &[String]) -> Result<(), LocalRunnerError> {
|
||||
let status = child.wait().map_err(|error| {
|
||||
LocalRunnerError::invalid(format!("failed to wait for Codex app-server: {error}"))
|
||||
})?;
|
||||
stdout_relay
|
||||
.join()
|
||||
.map_err(|_| LocalRunnerError::invalid("Codex app-server stdout relay panicked"))?
|
||||
.map_err(|error| {
|
||||
LocalRunnerError::invalid(format!("Codex app-server stdout relay failed: {error}"))
|
||||
})?;
|
||||
eprintln!(
|
||||
"paperclip-runnerd: native codex proxy exited success={} code={}",
|
||||
status.success(),
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
assertCapabilityLiveSessionSnapshot,
|
||||
type CapabilityLiveSessionSnapshot,
|
||||
type CapabilityLiveSessionStore,
|
||||
} from "./live-session.js";
|
||||
|
||||
const DURABLE_STORE_SCHEMA = "paperclip.capability.live-session-checkpoint.v1" as const;
|
||||
|
||||
export interface CapabilityLiveSessionStoreBinding {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
companyId: string;
|
||||
actorId: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface DurableCheckpointEnvelope {
|
||||
schema: typeof DURABLE_STORE_SCHEMA;
|
||||
binding: CapabilityLiveSessionStoreBinding;
|
||||
snapshotSha256: string;
|
||||
snapshot: CapabilityLiveSessionSnapshot;
|
||||
}
|
||||
|
||||
export interface DurableCapabilityLiveSessionStoreOptions {
|
||||
directory: string;
|
||||
binding: CapabilityLiveSessionStoreBinding;
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function requireIdentifier(value: string, name: string): void {
|
||||
if (value.trim().length === 0) throw new Error(`capability_live_store_${name}_required`);
|
||||
}
|
||||
|
||||
function sameBinding(
|
||||
left: CapabilityLiveSessionStoreBinding,
|
||||
right: CapabilityLiveSessionStoreBinding,
|
||||
): boolean {
|
||||
return left.sessionId === right.sessionId
|
||||
&& left.runId === right.runId
|
||||
&& left.companyId === right.companyId
|
||||
&& left.actorId === right.actorId
|
||||
&& left.taskId === right.taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt-bound, crash-safe checkpoint storage for the governed live path.
|
||||
*
|
||||
* Each replacement is written, fsynced, renamed, and followed by a directory
|
||||
* fsync. A checksum, schema check, revision check, and immutable run binding
|
||||
* make partial writes, stale writers, corrupt JSON, and cross-attempt reuse
|
||||
* fail closed rather than silently opening another provider thread.
|
||||
*/
|
||||
export class DurableCapabilityLiveSessionStore implements CapabilityLiveSessionStore {
|
||||
readonly #directory: string;
|
||||
readonly #binding: CapabilityLiveSessionStoreBinding;
|
||||
readonly #checkpointPath: string;
|
||||
|
||||
constructor(options: DurableCapabilityLiveSessionStoreOptions) {
|
||||
this.#directory = resolve(options.directory);
|
||||
this.#binding = structuredClone(options.binding);
|
||||
for (const [name, value] of Object.entries(this.#binding)) requireIdentifier(value, name);
|
||||
const key = sha256(this.#binding.sessionId);
|
||||
this.#checkpointPath = join(this.#directory, `${key}.json`);
|
||||
}
|
||||
|
||||
async load(sessionId: string): Promise<CapabilityLiveSessionSnapshot | null> {
|
||||
this.#assertSessionId(sessionId);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(this.#checkpointPath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
let envelope: DurableCheckpointEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw) as DurableCheckpointEnvelope;
|
||||
} catch {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid JSON");
|
||||
}
|
||||
if (envelope.schema !== DURABLE_STORE_SCHEMA) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: unsupported envelope schema");
|
||||
}
|
||||
if (!sameBinding(envelope.binding, this.#binding)) {
|
||||
throw new Error("capability_live_checkpoint_binding_mismatch");
|
||||
}
|
||||
assertCapabilityLiveSessionSnapshot(envelope.snapshot);
|
||||
const serialized = JSON.stringify(envelope.snapshot);
|
||||
if (sha256(serialized) !== envelope.snapshotSha256) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: checksum mismatch");
|
||||
}
|
||||
this.#assertSnapshotBinding(envelope.snapshot);
|
||||
return structuredClone(envelope.snapshot);
|
||||
}
|
||||
|
||||
async save(snapshot: CapabilityLiveSessionSnapshot): Promise<void> {
|
||||
assertCapabilityLiveSessionSnapshot(snapshot);
|
||||
this.#assertSnapshotBinding(snapshot);
|
||||
await mkdir(this.#directory, { recursive: true, mode: 0o700 });
|
||||
|
||||
const current = await this.load(snapshot.sessionId);
|
||||
if (current !== null) {
|
||||
if (current.revision > snapshot.revision) {
|
||||
throw new Error("capability_live_checkpoint_stale_revision");
|
||||
}
|
||||
if (current.revision === snapshot.revision) {
|
||||
if (JSON.stringify(current) === JSON.stringify(snapshot)) return;
|
||||
throw new Error("capability_live_checkpoint_revision_conflict");
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const envelope: DurableCheckpointEnvelope = {
|
||||
schema: DURABLE_STORE_SCHEMA,
|
||||
binding: structuredClone(this.#binding),
|
||||
snapshotSha256: sha256(serialized),
|
||||
snapshot: structuredClone(snapshot),
|
||||
};
|
||||
const temporaryPath = `${this.#checkpointPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
const file = await open(temporaryPath, "wx", 0o600);
|
||||
try {
|
||||
await file.writeFile(`${JSON.stringify(envelope)}\n`, "utf8");
|
||||
await file.sync();
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
try {
|
||||
await rename(temporaryPath, this.#checkpointPath);
|
||||
const directory = await open(this.#directory, "r");
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(sessionId: string): Promise<void> {
|
||||
this.#assertSessionId(sessionId);
|
||||
await unlink(this.#checkpointPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
|
||||
#assertSessionId(sessionId: string): void {
|
||||
if (sessionId !== this.#binding.sessionId) {
|
||||
throw new Error("capability_live_checkpoint_binding_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#assertSnapshotBinding(snapshot: CapabilityLiveSessionSnapshot): void {
|
||||
const authority = snapshot.authority;
|
||||
const actual: CapabilityLiveSessionStoreBinding = {
|
||||
sessionId: snapshot.sessionId,
|
||||
runId: authority.runId,
|
||||
companyId: authority.companyId,
|
||||
actorId: authority.actorId,
|
||||
taskId: authority.taskId,
|
||||
};
|
||||
if (!sameBinding(actual, this.#binding)) {
|
||||
throw new Error("capability_live_checkpoint_binding_mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "../scenarios/index.js";
|
||||
export * from "./clean-room.js";
|
||||
export * from "./live-session.js";
|
||||
export * from "./durable-live-session-store.js";
|
||||
export * from "./runnerd-codex-transport.js";
|
||||
export * from "./turn-stream.js";
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type {
|
||||
CodexAppServerTransport,
|
||||
@@ -9,8 +15,11 @@ import type {
|
||||
import {
|
||||
InMemoryCapabilityLiveSessionStore,
|
||||
CapabilityLiveSessionService,
|
||||
reconcileCapabilityLiveUsage,
|
||||
type CapabilityLiveTransportFactory,
|
||||
} from "./live-session.js";
|
||||
import { DurableCapabilityLiveSessionStore } from "./durable-live-session-store.js";
|
||||
import { defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
|
||||
|
||||
class AsyncNotifications implements AsyncIterable<CodexRpcNotification> {
|
||||
#values: CodexRpcNotification[] = [];
|
||||
@@ -46,6 +55,8 @@ interface FakeProviderState {
|
||||
nextTurn: number;
|
||||
lastToolResult: string;
|
||||
transports: FakeCapabilityCodexTransport[];
|
||||
turns: Map<string, string>;
|
||||
holdAfterTool: boolean;
|
||||
}
|
||||
|
||||
class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
@@ -74,7 +85,13 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
};
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
return { thread: { id: this.state.threadId, sessionId: this.state.providerSessionId, turns: [] } };
|
||||
return {
|
||||
thread: {
|
||||
id: this.state.threadId,
|
||||
sessionId: this.state.providerSessionId,
|
||||
turns: [...this.state.turns].map(([id, status]) => ({ id, status })),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return {
|
||||
@@ -86,6 +103,7 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
if (method === "turn/start") {
|
||||
const turnId = `turn-${++this.state.nextTurn}`;
|
||||
this.#activeTurnId = turnId;
|
||||
this.state.turns.set(turnId, "inProgress");
|
||||
const input = Array.isArray(params.input) ? params.input[0] as Record<string, unknown> : {};
|
||||
const message = String(input.text ?? "");
|
||||
void this.#runTurn(turnId, message);
|
||||
@@ -93,6 +111,7 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
const turnId = String(params.turnId);
|
||||
this.state.turns.set(turnId, "interrupted");
|
||||
this.notificationsQueue.push({
|
||||
method: "turn/completed",
|
||||
params: { threadId: this.state.threadId, turn: { id: turnId, status: "interrupted" } },
|
||||
@@ -139,6 +158,9 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
let assistantText: string;
|
||||
if (message.includes("progress")) {
|
||||
const idempotencyKey = message.includes("idempotent")
|
||||
? "progress-governed-once"
|
||||
: `progress-${turnId}`;
|
||||
const result = await this.#toolCall({
|
||||
id: `request-${turnId}`,
|
||||
method: "item/tool/call",
|
||||
@@ -148,12 +170,13 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
callId: `call-${turnId}`,
|
||||
tool: "report_progress",
|
||||
arguments: {
|
||||
idempotencyKey: `progress-${turnId}`,
|
||||
idempotencyKey,
|
||||
body: "Progress persisted through the live Codex tool loop.",
|
||||
},
|
||||
},
|
||||
});
|
||||
this.state.lastToolResult = String((result.contentItems as Array<Record<string, unknown>>)[0]?.text);
|
||||
if (this.state.holdAfterTool) await new Promise<never>(() => undefined);
|
||||
assistantText = `The typed result changed my response: ${this.state.lastToolResult}`;
|
||||
} else if (message.includes("semantic-interaction-result")) {
|
||||
assistantText = `Resumed the same provider thread with ${message}`;
|
||||
@@ -191,6 +214,7 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
item: { id: `message-${turnId}`, type: "agentMessage", text: assistantText },
|
||||
},
|
||||
});
|
||||
this.state.turns.set(turnId, "completed");
|
||||
this.notificationsQueue.push({
|
||||
method: "turn/completed",
|
||||
params: { threadId: this.state.threadId, turn: { id: turnId, status: "completed" } },
|
||||
@@ -231,6 +255,8 @@ function providerState(): FakeProviderState {
|
||||
nextTurn: 0,
|
||||
lastToolResult: "nothing yet",
|
||||
transports: [],
|
||||
turns: new Map(),
|
||||
holdAfterTool: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,4 +358,241 @@ describe("Capability live runnerd and Codex session", () => {
|
||||
expect(stopped.process?.runnerExited).toBe(true);
|
||||
expect((await store.load(replacement.id))?.status).toBe("closed");
|
||||
});
|
||||
|
||||
it("durably resumes a distinct attempt, preserves partial usage, and deduplicates the semantic effect", async () => {
|
||||
const state = providerState();
|
||||
const directory = await mkdtemp(join(tmpdir(), "capability-live-durable-"));
|
||||
const binding = {
|
||||
sessionId: "session-governed-resume",
|
||||
runId: "run-governed-resume",
|
||||
companyId: "company-1",
|
||||
actorId: "actor-1",
|
||||
taskId: "task-1",
|
||||
};
|
||||
const firstStore = new DurableCapabilityLiveSessionStore({ directory, binding });
|
||||
const firstService = new CapabilityLiveSessionService({
|
||||
store: firstStore,
|
||||
transportFactory: fakeTransportFactory(state),
|
||||
});
|
||||
const first = await firstService.create({
|
||||
...binding,
|
||||
attemptId: "attempt-killed",
|
||||
turnTimeoutMs: 500,
|
||||
});
|
||||
state.holdAfterTool = true;
|
||||
const killedTurn = first.sendMessage("Apply idempotent progress once.");
|
||||
await vi.waitFor(async () => {
|
||||
expect((await firstStore.load(binding.sessionId))?.mockState).toContain(
|
||||
"Progress persisted through the live Codex tool loop.",
|
||||
);
|
||||
});
|
||||
await expect(first.recordUsage({
|
||||
receiptId: "provider-response-1",
|
||||
providerResponseId: "response-1",
|
||||
turnId: "turn-1",
|
||||
providerCalls: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cachedInputTokens: 40,
|
||||
reasoningTokens: 5,
|
||||
costNanodollars: 1_500,
|
||||
})).resolves.toBe("committed");
|
||||
await state.transports[0]?.close();
|
||||
await expect(killedTurn).rejects.toThrow("timed out");
|
||||
|
||||
// A new service models worker termination: it owns no in-memory session.
|
||||
const resumedService = new CapabilityLiveSessionService({
|
||||
store: new DurableCapabilityLiveSessionStore({ directory, binding }),
|
||||
transportFactory: fakeTransportFactory(state),
|
||||
});
|
||||
const resumed = await resumedService.resume({
|
||||
sessionId: binding.sessionId,
|
||||
attemptId: "attempt-resumed",
|
||||
resumeOf: "attempt-killed",
|
||||
});
|
||||
expect(resumed.snapshot().providerThreadId).toBe(state.threadId);
|
||||
expect(resumed.snapshot().attempts).toMatchObject([
|
||||
{ attemptId: "attempt-killed", status: "terminated", failureCode: "worker_terminated" },
|
||||
{ attemptId: "attempt-resumed", resumeOf: "attempt-killed", status: "running" },
|
||||
]);
|
||||
|
||||
await expect(resumed.reconcileActiveTurn()).resolves.toMatchObject({
|
||||
turnId: "turn-1",
|
||||
status: "interrupted",
|
||||
});
|
||||
state.holdAfterTool = false;
|
||||
|
||||
const duplicate = await resumed.sendMessage("Apply idempotent progress once.");
|
||||
expect(duplicate.assistantText).toContain('"disposition":"duplicate"');
|
||||
expect(resumed.mockState().comments).toHaveLength(1);
|
||||
await expect(resumed.recordUsage({
|
||||
receiptId: "provider-response-1",
|
||||
providerResponseId: "response-1",
|
||||
turnId: "turn-1",
|
||||
providerCalls: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cachedInputTokens: 40,
|
||||
reasoningTokens: 5,
|
||||
costNanodollars: 1_500,
|
||||
})).resolves.toBe("duplicate");
|
||||
await resumed.recordUsage({
|
||||
receiptId: "provider-response-2",
|
||||
providerResponseId: "response-2",
|
||||
providerCalls: 1,
|
||||
inputTokens: 80,
|
||||
outputTokens: 10,
|
||||
costNanodollars: 1_000,
|
||||
});
|
||||
await resumed.completeAttempt("succeeded");
|
||||
|
||||
const final = await firstStore.load(binding.sessionId);
|
||||
expect(final?.attempts).toMatchObject([
|
||||
{ attemptId: "attempt-killed", status: "terminated" },
|
||||
{ attemptId: "attempt-resumed", status: "succeeded" },
|
||||
]);
|
||||
expect(final?.usageLedger).toHaveLength(2);
|
||||
expect(final && reconcileCapabilityLiveUsage(final)).toEqual({
|
||||
providerCalls: 2,
|
||||
inputTokens: 180,
|
||||
outputTokens: 30,
|
||||
cachedInputTokens: 40,
|
||||
reasoningTokens: 5,
|
||||
costNanodollars: 2_500,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for missing, corrupt, mismatched, and provider-drifted checkpoints", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "capability-live-fail-closed-"));
|
||||
const binding = {
|
||||
sessionId: "session-fail-closed",
|
||||
runId: "run-fail-closed",
|
||||
companyId: "company-1",
|
||||
actorId: "actor-1",
|
||||
taskId: "task-1",
|
||||
};
|
||||
const missingService = new CapabilityLiveSessionService({
|
||||
store: new DurableCapabilityLiveSessionStore({ directory, binding }),
|
||||
transportFactory: fakeTransportFactory(providerState()),
|
||||
});
|
||||
await expect(missingService.resume({
|
||||
sessionId: binding.sessionId,
|
||||
attemptId: "attempt-missing-resume",
|
||||
resumeOf: "attempt-missing",
|
||||
})).rejects.toThrow("capability_live_resume_checkpoint_missing");
|
||||
|
||||
const state = providerState();
|
||||
const store = new DurableCapabilityLiveSessionStore({ directory, binding });
|
||||
const firstService = new CapabilityLiveSessionService({
|
||||
store,
|
||||
transportFactory: fakeTransportFactory(state),
|
||||
});
|
||||
await firstService.create({ ...binding, attemptId: "attempt-original" });
|
||||
const mismatched = new DurableCapabilityLiveSessionStore({
|
||||
directory,
|
||||
binding: { ...binding, taskId: "task-other" },
|
||||
});
|
||||
await expect(mismatched.load(binding.sessionId)).rejects.toThrow(
|
||||
"capability_live_checkpoint_binding_mismatch",
|
||||
);
|
||||
|
||||
state.providerSessionId = "codex-provider-drifted";
|
||||
const driftedService = new CapabilityLiveSessionService({
|
||||
store,
|
||||
transportFactory: fakeTransportFactory(state),
|
||||
});
|
||||
await expect(driftedService.resume({
|
||||
sessionId: binding.sessionId,
|
||||
attemptId: "attempt-drifted",
|
||||
resumeOf: "attempt-original",
|
||||
})).rejects.toThrow("capability_live_provider_session_drift");
|
||||
expect((await store.load(binding.sessionId))?.attempts?.at(-1)).toMatchObject({
|
||||
attemptId: "attempt-drifted",
|
||||
status: "failed",
|
||||
failureCode: "provider_recovery_failed",
|
||||
});
|
||||
|
||||
const checkpointFile = join(directory, `${createHash("sha256").update(binding.sessionId).digest("hex")}.json`);
|
||||
const checkpoint = await readFile(checkpointFile, "utf8");
|
||||
await writeFile(checkpointFile, checkpoint.replace('"snapshotSha256":"', '"snapshotSha256":"corrupt-'));
|
||||
await expect(store.load(binding.sessionId)).rejects.toThrow("capability_live_checkpoint_corrupt");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || !existsSync(defaultCapabilityRunnerdBinary()))(
|
||||
"terminates real runnerd after a durable receipt and resumes its exact provider thread",
|
||||
async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "capability-live-real-runnerd-"));
|
||||
const providerStatePath = join(directory, "provider-state.json");
|
||||
const fixture = fileURLToPath(new URL("../../test/fixtures/fake-durable-codex-app-server.mjs", import.meta.url));
|
||||
const binding = {
|
||||
sessionId: "session-real-runnerd-resume",
|
||||
runId: "run-real-runnerd-resume",
|
||||
companyId: "company-1",
|
||||
actorId: "actor-1",
|
||||
taskId: "task-1",
|
||||
};
|
||||
const store = new DurableCapabilityLiveSessionStore({ directory, binding });
|
||||
const transportOptions = {
|
||||
codexCommand: process.execPath,
|
||||
codexArgs: [fixture, providerStatePath],
|
||||
closeGraceMs: 100,
|
||||
};
|
||||
const firstService = new CapabilityLiveSessionService({ store, transportOptions });
|
||||
const first = await firstService.create({
|
||||
...binding,
|
||||
attemptId: "attempt-real-killed",
|
||||
turnTimeoutMs: 2_000,
|
||||
});
|
||||
const killedTurn = first.sendMessage("Apply the governed idempotent effect.");
|
||||
await vi.waitFor(async () => {
|
||||
const checkpoint = await store.load(binding.sessionId);
|
||||
expect(checkpoint?.mockState).toContain("One durable governed effect.");
|
||||
expect(checkpoint?.activeTurnId).toBe("turn-1");
|
||||
expect(checkpoint?.process?.runnerProcessGroupId).not.toBeNull();
|
||||
expect(checkpoint?.process?.codexPid).not.toBeNull();
|
||||
});
|
||||
await first.recordUsage({
|
||||
receiptId: "real-response-1",
|
||||
providerResponseId: "fixture-response-1",
|
||||
turnId: "turn-1",
|
||||
providerCalls: 1,
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
costNanodollars: 100,
|
||||
});
|
||||
const killedCheckpoint = await store.load(binding.sessionId);
|
||||
const processGroupId = killedCheckpoint?.process?.runnerProcessGroupId;
|
||||
expect(processGroupId).toBeTypeOf("number");
|
||||
process.kill(-processGroupId!, "SIGKILL");
|
||||
await expect(killedTurn).rejects.toThrow();
|
||||
|
||||
const resumedService = new CapabilityLiveSessionService({
|
||||
store: new DurableCapabilityLiveSessionStore({ directory, binding }),
|
||||
transportOptions,
|
||||
});
|
||||
const resumed = await resumedService.resume({
|
||||
sessionId: binding.sessionId,
|
||||
attemptId: "attempt-real-resumed",
|
||||
resumeOf: "attempt-real-killed",
|
||||
});
|
||||
expect(resumed.snapshot().providerThreadId).toBe("thread-durable-runnerd");
|
||||
await expect(resumed.reconcileActiveTurn()).resolves.toMatchObject({ status: "interrupted" });
|
||||
const duplicate = await resumed.sendMessage("Apply the governed idempotent effect again.");
|
||||
expect(duplicate.assistantText).toContain("duplicate");
|
||||
expect(resumed.mockState().comments).toHaveLength(1);
|
||||
await resumed.completeAttempt("succeeded");
|
||||
const final = await store.load(binding.sessionId);
|
||||
expect(final?.attempts).toMatchObject([
|
||||
{ attemptId: "attempt-real-killed", status: "terminated" },
|
||||
{ attemptId: "attempt-real-resumed", status: "succeeded", resumeOf: "attempt-real-killed" },
|
||||
]);
|
||||
expect(final?.usageLedger).toHaveLength(1);
|
||||
expect(final?.terminalTurns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ turnId: "turn-1", status: "interrupted" }),
|
||||
expect.objectContaining({ turnId: "turn-2", status: "completed" }),
|
||||
]));
|
||||
await resumedService.shutdown(resumed.id, "test complete");
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
@@ -95,6 +95,50 @@ export interface CapabilityLiveSessionConfigSnapshot {
|
||||
turnTimeoutMs: number;
|
||||
}
|
||||
|
||||
export type CapabilityLiveAttemptStatus = "running" | "succeeded" | "failed" | "terminated";
|
||||
|
||||
export interface CapabilityLiveAttemptSnapshot {
|
||||
attemptId: string;
|
||||
resumeOf: string | null;
|
||||
status: CapabilityLiveAttemptStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
failureCode: string | null;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveTurnTerminalFact {
|
||||
turnId: string;
|
||||
status: CapabilityLiveTurnResult["status"];
|
||||
reconciledByAttemptId: string;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveUsageReceipt {
|
||||
receiptId: string;
|
||||
attemptId: string;
|
||||
providerResponseId: string | null;
|
||||
turnId: string | null;
|
||||
providerCalls: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
costNanodollars: number;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface RecordCapabilityLiveUsageInput {
|
||||
receiptId: string;
|
||||
providerResponseId?: string | null;
|
||||
turnId?: string | null;
|
||||
providerCalls?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
costNanodollars?: number;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveSessionSnapshot {
|
||||
schema: typeof LIVE_SESSION_SCHEMA;
|
||||
revision: number;
|
||||
@@ -118,6 +162,13 @@ export interface CapabilityLiveSessionSnapshot {
|
||||
realPaperclipRequests: 0;
|
||||
childPaperclipEnvironmentKeys: string[];
|
||||
};
|
||||
/** Governed worker attempts. A resumed worker is a new attempt, never a rewrite. */
|
||||
attempts?: CapabilityLiveAttemptSnapshot[];
|
||||
currentAttemptId?: string;
|
||||
/** Provider terminal facts reconciled by the attempt that observed them. */
|
||||
terminalTurns?: CapabilityLiveTurnTerminalFact[];
|
||||
/** Exact-once provider usage/cost receipts retained across every attempt. */
|
||||
usageLedger?: CapabilityLiveUsageReceipt[];
|
||||
}
|
||||
|
||||
export interface CreateCapabilityLiveSessionInput {
|
||||
@@ -131,9 +182,16 @@ export interface CreateCapabilityLiveSessionInput {
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
attemptId?: string;
|
||||
turnTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ResumeCapabilityLiveSessionInput {
|
||||
sessionId: string;
|
||||
attemptId: string;
|
||||
resumeOf: string;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveTurnResult {
|
||||
turnId: string;
|
||||
status: "completed" | "failed" | "interrupted" | "cancelled";
|
||||
@@ -268,6 +326,158 @@ function jsonValue(value: unknown): CapabilityJsonValue {
|
||||
return JSON.parse(JSON.stringify(value ?? null)) as CapabilityJsonValue;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: number | undefined, name: string): number {
|
||||
const normalized = value ?? 0;
|
||||
if (!Number.isSafeInteger(normalized) || normalized < 0) {
|
||||
throw new Error(`capability_live_usage_${name}_invalid`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, name: string): string {
|
||||
if (value.trim().length === 0) throw new Error(`capability_live_${name}_required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerTurnStatus(value: unknown): CapabilityLiveTurnResult["status"] | null {
|
||||
const status = text(value).toLowerCase();
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "interrupted") return "interrupted";
|
||||
if (status === "cancelled" || status === "canceled") return "cancelled";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reject malformed or partially written checkpoints before any provider process starts. */
|
||||
export function assertCapabilityLiveSessionSnapshot(
|
||||
value: unknown,
|
||||
): asserts value is CapabilityLiveSessionSnapshot {
|
||||
const snapshot = record(value);
|
||||
if (snapshot.schema !== LIVE_SESSION_SCHEMA) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: unsupported snapshot schema");
|
||||
}
|
||||
for (const name of ["sessionId", "providerThreadId", "mockState", "createdAt", "updatedAt"] as const) {
|
||||
if (text(snapshot[name]).length === 0) {
|
||||
throw new Error(`capability_live_checkpoint_corrupt: missing ${name}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(snapshot.revision) || Number(snapshot.revision) < 1) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid revision");
|
||||
}
|
||||
const authority = record(snapshot.authority);
|
||||
for (const name of ["runId", "companyId", "actorId", "taskId", "sessionId"] as const) {
|
||||
if (text(authority[name]).length === 0) {
|
||||
throw new Error(`capability_live_checkpoint_corrupt: missing authority.${name}`);
|
||||
}
|
||||
}
|
||||
if (authority.sessionId !== snapshot.sessionId) {
|
||||
throw new Error("capability_live_checkpoint_binding_mismatch");
|
||||
}
|
||||
if (typeof snapshot.mockState !== "string") {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid mockState");
|
||||
}
|
||||
try {
|
||||
JSON.parse(snapshot.mockState);
|
||||
} catch {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid mockState JSON");
|
||||
}
|
||||
if (snapshot.attempts !== undefined && !Array.isArray(snapshot.attempts)) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid attempts");
|
||||
}
|
||||
const attemptIds = new Set<string>();
|
||||
for (const value of snapshot.attempts ?? []) {
|
||||
const attempt = record(value);
|
||||
const attemptId = text(attempt.attemptId);
|
||||
if (
|
||||
attemptId.length === 0 ||
|
||||
attemptIds.has(attemptId) ||
|
||||
!["running", "succeeded", "failed", "terminated"].includes(text(attempt.status)) ||
|
||||
text(attempt.startedAt).length === 0
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid attempt");
|
||||
}
|
||||
const resumeOf = attempt.resumeOf === null ? null : text(attempt.resumeOf);
|
||||
if (resumeOf !== null && !attemptIds.has(resumeOf)) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid attempt lineage");
|
||||
}
|
||||
attemptIds.add(attemptId);
|
||||
}
|
||||
if (
|
||||
snapshot.currentAttemptId !== undefined &&
|
||||
!attemptIds.has(text(snapshot.currentAttemptId))
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid current attempt");
|
||||
}
|
||||
if (snapshot.terminalTurns !== undefined && !Array.isArray(snapshot.terminalTurns)) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid terminalTurns");
|
||||
}
|
||||
const terminalTurnIds = new Set<string>();
|
||||
for (const value of snapshot.terminalTurns ?? []) {
|
||||
const terminal = record(value);
|
||||
const turnId = text(terminal.turnId);
|
||||
if (
|
||||
turnId.length === 0 ||
|
||||
terminalTurnIds.has(turnId) ||
|
||||
!["completed", "failed", "interrupted", "cancelled"].includes(text(terminal.status)) ||
|
||||
text(terminal.reconciledByAttemptId).length === 0 ||
|
||||
text(terminal.observedAt).length === 0
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid terminal fact");
|
||||
}
|
||||
terminalTurnIds.add(turnId);
|
||||
}
|
||||
if (snapshot.usageLedger !== undefined && !Array.isArray(snapshot.usageLedger)) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid usageLedger");
|
||||
}
|
||||
const usageReceiptIds = new Set<string>();
|
||||
for (const value of snapshot.usageLedger ?? []) {
|
||||
const receipt = record(value);
|
||||
const receiptId = text(receipt.receiptId);
|
||||
if (
|
||||
receiptId.length === 0 ||
|
||||
usageReceiptIds.has(receiptId) ||
|
||||
text(receipt.attemptId).length === 0 ||
|
||||
text(receipt.observedAt).length === 0
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid usage receipt");
|
||||
}
|
||||
for (const name of [
|
||||
"providerCalls",
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"reasoningTokens",
|
||||
"costNanodollars",
|
||||
] as const) {
|
||||
if (!Number.isSafeInteger(receipt[name]) || Number(receipt[name]) < 0) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid usage receipt");
|
||||
}
|
||||
}
|
||||
usageReceiptIds.add(receiptId);
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileCapabilityLiveUsage(
|
||||
snapshot: CapabilityLiveSessionSnapshot,
|
||||
): Omit<CapabilityLiveUsageReceipt, "receiptId" | "attemptId" | "providerResponseId" | "turnId" | "observedAt"> {
|
||||
const unique = new Map((snapshot.usageLedger ?? []).map((receipt) => [receipt.receiptId, receipt]));
|
||||
return [...unique.values()].reduce((total, receipt) => ({
|
||||
providerCalls: total.providerCalls + receipt.providerCalls,
|
||||
inputTokens: total.inputTokens + receipt.inputTokens,
|
||||
outputTokens: total.outputTokens + receipt.outputTokens,
|
||||
cachedInputTokens: total.cachedInputTokens + receipt.cachedInputTokens,
|
||||
reasoningTokens: total.reasoningTokens + receipt.reasoningTokens,
|
||||
costNanodollars: total.costNanodollars + receipt.costNanodollars,
|
||||
}), {
|
||||
providerCalls: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
costNanodollars: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function dynamicToolSpec(definition: CapabilitySemanticToolDefinition): Record<string, unknown> {
|
||||
return {
|
||||
name: definition.name,
|
||||
@@ -352,6 +562,7 @@ export class CapabilityLiveSessionService {
|
||||
transportFactory: this.#transportFactory,
|
||||
transportOptions: this.#transportOptions,
|
||||
now: this.#now,
|
||||
attemptId: input.attemptId ?? runId,
|
||||
});
|
||||
this.#sessions.set(session.id, session);
|
||||
return session;
|
||||
@@ -362,6 +573,7 @@ export class CapabilityLiveSessionService {
|
||||
if (live !== undefined) return live;
|
||||
const snapshot = await this.#store.load(sessionId);
|
||||
if (snapshot === null) throw new Error(`capability live session ${sessionId} was not found`);
|
||||
assertCapabilityLiveSessionSnapshot(snapshot);
|
||||
const session = await CapabilityLiveSession.restore({
|
||||
snapshot,
|
||||
store: this.#store,
|
||||
@@ -373,6 +585,92 @@ export class CapabilityLiveSessionService {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production worker-restart entrypoint. It records the killed attempt and its
|
||||
* successor durably before runnerd/Codex is started, then restores only the
|
||||
* provider thread named by that checkpoint.
|
||||
*/
|
||||
async resume(input: ResumeCapabilityLiveSessionInput): Promise<CapabilityLiveSession> {
|
||||
requireNonEmpty(input.attemptId, "attempt_id");
|
||||
requireNonEmpty(input.resumeOf, "resume_of");
|
||||
if (input.attemptId === input.resumeOf) throw new Error("capability_live_resume_cycle");
|
||||
if (this.#sessions.has(input.sessionId)) {
|
||||
throw new Error("capability_live_resume_requires_new_worker");
|
||||
}
|
||||
const snapshot = await this.#store.load(input.sessionId);
|
||||
if (snapshot === null) {
|
||||
throw new Error("capability_live_resume_checkpoint_missing: refusing to open a second provider session");
|
||||
}
|
||||
assertCapabilityLiveSessionSnapshot(snapshot);
|
||||
const attempts = structuredClone(snapshot.attempts ?? [{
|
||||
attemptId: snapshot.authority.runId,
|
||||
resumeOf: null,
|
||||
status: "running" as const,
|
||||
startedAt: snapshot.createdAt,
|
||||
finishedAt: null,
|
||||
failureCode: null,
|
||||
}]);
|
||||
const currentAttemptId = snapshot.currentAttemptId ?? attempts.at(-1)?.attemptId;
|
||||
if (currentAttemptId !== input.resumeOf) throw new Error("capability_live_resume_binding_mismatch");
|
||||
if (attempts.some((attempt) => attempt.attemptId === input.attemptId)) {
|
||||
throw new Error("capability_live_resume_attempt_exists");
|
||||
}
|
||||
const previous = attempts.find((attempt) => attempt.attemptId === input.resumeOf);
|
||||
if (previous === undefined || previous.status !== "running") {
|
||||
throw new Error("capability_live_resume_source_not_running");
|
||||
}
|
||||
const resumedAt = this.#now().toISOString();
|
||||
previous.status = "terminated";
|
||||
previous.finishedAt = resumedAt;
|
||||
previous.failureCode = "worker_terminated";
|
||||
attempts.push({
|
||||
attemptId: input.attemptId,
|
||||
resumeOf: input.resumeOf,
|
||||
status: "running",
|
||||
startedAt: resumedAt,
|
||||
finishedAt: null,
|
||||
failureCode: null,
|
||||
});
|
||||
const prepared: CapabilityLiveSessionSnapshot = {
|
||||
...snapshot,
|
||||
revision: snapshot.revision + 1,
|
||||
updatedAt: resumedAt,
|
||||
status: "starting",
|
||||
attempts,
|
||||
currentAttemptId: input.attemptId,
|
||||
};
|
||||
await this.#store.save(prepared);
|
||||
try {
|
||||
const session = await CapabilityLiveSession.restore({
|
||||
snapshot: prepared,
|
||||
store: this.#store,
|
||||
transportFactory: this.#transportFactory,
|
||||
transportOptions: this.#transportOptions,
|
||||
now: this.#now,
|
||||
});
|
||||
this.#sessions.set(session.id, session);
|
||||
return session;
|
||||
} catch (error) {
|
||||
const failedAt = this.#now().toISOString();
|
||||
const latest = await this.#store.load(input.sessionId) ?? prepared;
|
||||
const failedAttempts = structuredClone(latest.attempts ?? []);
|
||||
const failed = failedAttempts.find((attempt) => attempt.attemptId === input.attemptId);
|
||||
if (failed !== undefined) {
|
||||
failed.status = "failed";
|
||||
failed.finishedAt = failedAt;
|
||||
failed.failureCode = "provider_recovery_failed";
|
||||
}
|
||||
await this.#store.save({
|
||||
...latest,
|
||||
revision: latest.revision + 1,
|
||||
updatedAt: failedAt,
|
||||
status: "failed",
|
||||
attempts: failedAttempts,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reconnect(sessionId: string): Promise<CapabilityLiveSessionSnapshot> {
|
||||
const session = await this.restore(sessionId);
|
||||
await session.reconnect();
|
||||
@@ -419,9 +717,10 @@ interface OpenSessionOptions {
|
||||
transportFactory: CapabilityLiveTransportFactory;
|
||||
transportOptions: CapabilityRunnerdCodexTransportOptions;
|
||||
now: () => Date;
|
||||
attemptId: string;
|
||||
}
|
||||
|
||||
interface RestoreSessionOptions extends Omit<OpenSessionOptions, "port" | "authority" | "config"> {
|
||||
interface RestoreSessionOptions extends Omit<OpenSessionOptions, "port" | "authority" | "config" | "attemptId"> {
|
||||
snapshot: CapabilityLiveSessionSnapshot;
|
||||
}
|
||||
|
||||
@@ -438,6 +737,10 @@ export class CapabilityLiveSession {
|
||||
readonly #transcript: CapabilityLiveTranscriptEntry[];
|
||||
readonly #evidence: CapabilityLiveEvidenceEntry[];
|
||||
readonly #priorAuthorizationRecords: CapabilitySemanticAuthorizationRecord[];
|
||||
readonly #attempts: CapabilityLiveAttemptSnapshot[];
|
||||
readonly #terminalTurns: CapabilityLiveTurnTerminalFact[];
|
||||
readonly #usageLedger: CapabilityLiveUsageReceipt[];
|
||||
#currentAttemptId: string;
|
||||
#transport: CodexAppServerTransport | null = null;
|
||||
#processEvidence: CapabilityRunnerdProcessEvidence | null = null;
|
||||
#providerThreadId = "";
|
||||
@@ -472,6 +775,18 @@ export class CapabilityLiveSession {
|
||||
this.#transcript = structuredClone(options.snapshot?.transcript ?? []);
|
||||
this.#evidence = structuredClone(options.snapshot?.evidence ?? []);
|
||||
this.#priorAuthorizationRecords = structuredClone(options.snapshot?.authorizationRecords ?? []);
|
||||
const initialAttemptId = options.snapshot?.currentAttemptId ?? options.attemptId;
|
||||
this.#attempts = structuredClone(options.snapshot?.attempts ?? [{
|
||||
attemptId: initialAttemptId,
|
||||
resumeOf: null,
|
||||
status: "running",
|
||||
startedAt: this.#createdAt,
|
||||
finishedAt: null,
|
||||
failureCode: null,
|
||||
}]);
|
||||
this.#currentAttemptId = initialAttemptId;
|
||||
this.#terminalTurns = structuredClone(options.snapshot?.terminalTurns ?? []);
|
||||
this.#usageLedger = structuredClone(options.snapshot?.usageLedger ?? []);
|
||||
this.#providerThreadId = options.snapshot?.providerThreadId ?? "";
|
||||
this.#providerSessionId = options.snapshot?.providerSessionId ?? null;
|
||||
this.#providerModel = options.snapshot?.providerModel === undefined
|
||||
@@ -484,7 +799,12 @@ export class CapabilityLiveSession {
|
||||
|
||||
static async open(options: OpenSessionOptions): Promise<CapabilityLiveSession> {
|
||||
const session = new CapabilityLiveSession(options);
|
||||
await session.#connect(false);
|
||||
try {
|
||||
await session.#connect(false);
|
||||
} catch (error) {
|
||||
await session.#abortConnect("initial provider connection failed");
|
||||
throw error;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -503,8 +823,14 @@ export class CapabilityLiveSession {
|
||||
authority: options.snapshot.authority,
|
||||
config: options.snapshot.config,
|
||||
snapshot: options.snapshot,
|
||||
attemptId: options.snapshot.currentAttemptId ?? options.snapshot.authority.runId,
|
||||
});
|
||||
await session.#connect(true);
|
||||
try {
|
||||
await session.#connect(true);
|
||||
} catch (error) {
|
||||
await session.#abortConnect("provider recovery failed");
|
||||
throw error;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -624,9 +950,65 @@ export class CapabilityLiveSession {
|
||||
realPaperclipRequests: 0,
|
||||
childPaperclipEnvironmentKeys: childKeys.filter((key) => key.startsWith("PAPERCLIP_")),
|
||||
},
|
||||
attempts: structuredClone(this.#attempts),
|
||||
currentAttemptId: this.#currentAttemptId,
|
||||
terminalTurns: structuredClone(this.#terminalTurns),
|
||||
usageLedger: structuredClone(this.#usageLedger),
|
||||
};
|
||||
}
|
||||
|
||||
async recordUsage(input: RecordCapabilityLiveUsageInput): Promise<"committed" | "duplicate"> {
|
||||
const receiptId = requireNonEmpty(input.receiptId, "usage_receipt_id");
|
||||
const receipt: CapabilityLiveUsageReceipt = {
|
||||
receiptId,
|
||||
attemptId: this.#currentAttemptId,
|
||||
providerResponseId: input.providerResponseId ?? null,
|
||||
turnId: input.turnId ?? this.#activeTurnId,
|
||||
providerCalls: nonNegativeInteger(input.providerCalls ?? 1, "provider_calls"),
|
||||
inputTokens: nonNegativeInteger(input.inputTokens, "input_tokens"),
|
||||
outputTokens: nonNegativeInteger(input.outputTokens, "output_tokens"),
|
||||
cachedInputTokens: nonNegativeInteger(input.cachedInputTokens, "cached_input_tokens"),
|
||||
reasoningTokens: nonNegativeInteger(input.reasoningTokens, "reasoning_tokens"),
|
||||
costNanodollars: nonNegativeInteger(input.costNanodollars, "cost_nanodollars"),
|
||||
observedAt: this.#now().toISOString(),
|
||||
};
|
||||
const existing = this.#usageLedger.find((candidate) => candidate.receiptId === receiptId);
|
||||
if (existing !== undefined) {
|
||||
const comparable = (value: CapabilityLiveUsageReceipt) => JSON.stringify({
|
||||
...value,
|
||||
attemptId: undefined,
|
||||
observedAt: undefined,
|
||||
});
|
||||
if (comparable(existing) !== comparable(receipt)) {
|
||||
throw new Error("capability_live_usage_receipt_conflict");
|
||||
}
|
||||
return "duplicate";
|
||||
}
|
||||
this.#usageLedger.push(receipt);
|
||||
await this.#persist();
|
||||
return "committed";
|
||||
}
|
||||
|
||||
async completeAttempt(
|
||||
status: Exclude<CapabilityLiveAttemptStatus, "running" | "terminated">,
|
||||
failureCode: string | null = null,
|
||||
): Promise<CapabilityLiveSessionSnapshot> {
|
||||
const attempt = this.#attempts.find((candidate) => candidate.attemptId === this.#currentAttemptId);
|
||||
if (attempt === undefined || attempt.status !== "running") {
|
||||
throw new Error("capability_live_attempt_not_running");
|
||||
}
|
||||
if (status === "succeeded" && this.#activeTurnId !== null) {
|
||||
throw new Error("capability_live_attempt_active_turn");
|
||||
}
|
||||
attempt.status = status;
|
||||
attempt.finishedAt = this.#now().toISOString();
|
||||
attempt.failureCode = status === "failed"
|
||||
? requireNonEmpty(failureCode ?? "attempt_failed", "attempt_failure_code")
|
||||
: null;
|
||||
await this.#persist();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async sendMessage(message: string): Promise<CapabilityLiveTurnResult> {
|
||||
const value = message.trim();
|
||||
if (value.length === 0) throw new Error("Capability live messages cannot be empty");
|
||||
@@ -643,21 +1025,7 @@ export class CapabilityLiveSession {
|
||||
// last transcript entry and the user message would never learn its turn.
|
||||
const userEntryId = this.#appendTranscript("user", value, null);
|
||||
let response: Record<string, unknown>;
|
||||
const terminal = new Promise<Omit<CapabilityLiveTurnResult, "snapshot">>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const waiter = this.#turnWaiter;
|
||||
this.#turnWaiter = null;
|
||||
if (waiter !== null) {
|
||||
this.#emit({
|
||||
turnId: this.#activeTurnId,
|
||||
kind: "error",
|
||||
message: `Capability Codex turn timed out after ${this.#config.turnTimeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
reject(new Error(`Capability Codex turn timed out after ${this.#config.turnTimeoutMs}ms`));
|
||||
}, this.#config.turnTimeoutMs);
|
||||
this.#turnWaiter = { resolve, reject, timer, assistantText: "", draftId: null };
|
||||
});
|
||||
const terminal = this.#armTurnWaiter();
|
||||
try {
|
||||
response = await this.#transport.request("turn/start", {
|
||||
threadId: this.#providerThreadId,
|
||||
@@ -693,6 +1061,45 @@ export class CapabilityLiveSession {
|
||||
return { ...result, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
#armTurnWaiter(): Promise<Omit<CapabilityLiveTurnResult, "snapshot">> {
|
||||
if (this.#turnWaiter !== null) throw new Error("Capability live session already has a turn waiter");
|
||||
return new Promise<Omit<CapabilityLiveTurnResult, "snapshot">>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const waiter = this.#turnWaiter;
|
||||
this.#turnWaiter = null;
|
||||
if (waiter !== null) {
|
||||
this.#emit({
|
||||
turnId: this.#activeTurnId,
|
||||
kind: "error",
|
||||
message: `Capability Codex turn timed out after ${this.#config.turnTimeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
reject(new Error(`Capability Codex turn timed out after ${this.#config.turnTimeoutMs}ms`));
|
||||
}, this.#config.turnTimeoutMs);
|
||||
this.#turnWaiter = { resolve, reject, timer, assistantText: "", draftId: null };
|
||||
});
|
||||
}
|
||||
|
||||
/** Interrupts and durably reconciles a checkpointed active turn after restart. */
|
||||
async reconcileActiveTurn(
|
||||
reason = "worker restarted while provider turn was active",
|
||||
): Promise<CapabilityLiveTurnResult | null> {
|
||||
if (this.#activeTurnId === null) return null;
|
||||
const turnId = this.#activeTurnId;
|
||||
const terminal = this.#armTurnWaiter();
|
||||
try {
|
||||
await this.interrupt(reason);
|
||||
} catch (error) {
|
||||
this.#rejectTurn(error);
|
||||
await terminal.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const result = await terminal;
|
||||
if (result.turnId !== turnId) throw new Error("capability_live_provider_session_drift: terminal turn mismatch");
|
||||
await this.#persist();
|
||||
return { ...result, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
async interrupt(reason = "operator interrupted turn"): Promise<CapabilityLiveSessionSnapshot> {
|
||||
if (this.#transport === null || this.#activeTurnId === null) return this.snapshot();
|
||||
this.#status = "stopping";
|
||||
@@ -811,7 +1218,27 @@ export class CapabilityLiveSession {
|
||||
});
|
||||
const thread = record(read.thread);
|
||||
if (text(thread.id) !== this.#providerThreadId) {
|
||||
throw new Error("Codex reconnect read a different provider thread");
|
||||
throw new Error("capability_live_provider_session_drift: thread/read identity mismatch");
|
||||
}
|
||||
const readSessionId = text(thread.sessionId);
|
||||
if (
|
||||
this.#providerSessionId !== null
|
||||
&& readSessionId.length > 0
|
||||
&& readSessionId !== this.#providerSessionId
|
||||
) {
|
||||
throw new Error("capability_live_provider_session_drift: thread/read session mismatch");
|
||||
}
|
||||
if (this.#activeTurnId !== null) {
|
||||
const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : [];
|
||||
const recoveredTurn = turns.find((turn) => text(turn.id) === this.#activeTurnId);
|
||||
if (recoveredTurn === undefined) {
|
||||
throw new Error("capability_live_provider_session_drift: checkpointed active turn missing");
|
||||
}
|
||||
const recoveredTerminal = providerTurnStatus(recoveredTurn.status);
|
||||
if (recoveredTerminal !== null) {
|
||||
this.#recordTerminalFact(this.#activeTurnId, recoveredTerminal);
|
||||
this.#activeTurnId = null;
|
||||
}
|
||||
}
|
||||
const resumed = await this.#transport.request("thread/resume", {
|
||||
threadId: this.#providerThreadId,
|
||||
@@ -824,11 +1251,18 @@ export class CapabilityLiveSession {
|
||||
});
|
||||
const resumedThread = record(resumed.thread);
|
||||
if (text(resumedThread.id) !== this.#providerThreadId) {
|
||||
throw new Error("Codex reconnect resumed a different provider thread");
|
||||
throw new Error("capability_live_provider_session_drift: thread/resume identity mismatch");
|
||||
}
|
||||
this.#providerSessionId = text(resumedThread.sessionId) || this.#providerSessionId;
|
||||
const resumedSessionId = text(resumedThread.sessionId);
|
||||
if (
|
||||
this.#providerSessionId !== null
|
||||
&& resumedSessionId.length > 0
|
||||
&& resumedSessionId !== this.#providerSessionId
|
||||
) {
|
||||
throw new Error("capability_live_provider_session_drift: thread/resume session mismatch");
|
||||
}
|
||||
this.#providerSessionId = resumedSessionId || this.#providerSessionId;
|
||||
this.#captureProviderModel(resumed);
|
||||
this.#activeTurnId = null;
|
||||
} else {
|
||||
const opened = await this.#transport.request("thread/start", {
|
||||
cwd: this.#config.workingDirectory,
|
||||
@@ -847,7 +1281,7 @@ export class CapabilityLiveSession {
|
||||
this.#providerSessionId = text(thread.sessionId) || text(record(initialized.user).sessionId) || null;
|
||||
this.#captureProviderModel(opened);
|
||||
}
|
||||
this.#status = "idle";
|
||||
this.#status = this.#activeTurnId === null ? "idle" : "running";
|
||||
this.#appendEvidence("session", null, {
|
||||
action: resume ? "resumed" : "started",
|
||||
sessionId: this.id,
|
||||
@@ -883,6 +1317,11 @@ export class CapabilityLiveSession {
|
||||
});
|
||||
}
|
||||
|
||||
async #abortConnect(reason: string): Promise<void> {
|
||||
await this.#disconnect(reason).catch(() => undefined);
|
||||
await this.#port.stop().catch(() => undefined);
|
||||
}
|
||||
|
||||
async #handleServerRequest(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
|
||||
if (request.method !== "item/tool/call") {
|
||||
return {
|
||||
@@ -894,6 +1333,19 @@ export class CapabilityLiveSession {
|
||||
const threadId = text(request.params.threadId);
|
||||
const turnId = text(request.params.turnId);
|
||||
const callId = text(request.params.callId, String(request.id));
|
||||
// Codex may issue its first tool request immediately after writing the
|
||||
// turn/start response, before the response and turn/started notification
|
||||
// have crossed the two async readers. Bind that request only while this
|
||||
// session is already waiting for the single turn it just started.
|
||||
if (
|
||||
this.#activeTurnId === null &&
|
||||
this.#turnWaiter !== null &&
|
||||
this.#status === "running" &&
|
||||
threadId === this.#providerThreadId &&
|
||||
turnId.length > 0
|
||||
) {
|
||||
this.#activeTurnId = turnId;
|
||||
}
|
||||
if (
|
||||
threadId !== this.#providerThreadId ||
|
||||
turnId.length === 0 ||
|
||||
@@ -1011,17 +1463,38 @@ export class CapabilityLiveSession {
|
||||
|
||||
#completeTurn(turnId: string, status: CapabilityLiveTurnResult["status"]): void {
|
||||
const waiter = this.#turnWaiter;
|
||||
if (waiter === null) return;
|
||||
if (waiter === null) {
|
||||
this.#recordTerminalFact(turnId, status);
|
||||
this.#activeTurnId = null;
|
||||
this.#status = "idle";
|
||||
this.#emit({ turnId, kind: "terminal", status, assistantText: "" });
|
||||
return;
|
||||
}
|
||||
clearTimeout(waiter.timer);
|
||||
this.#turnWaiter = null;
|
||||
const assistantText = waiter.assistantText.trim();
|
||||
this.#settleAssistantDraft(waiter, assistantText, turnId);
|
||||
this.#recordTerminalFact(turnId, status);
|
||||
this.#activeTurnId = null;
|
||||
this.#status = "idle";
|
||||
this.#emit({ turnId, kind: "terminal", status, assistantText });
|
||||
waiter.resolve({ turnId, status, assistantText });
|
||||
}
|
||||
|
||||
#recordTerminalFact(turnId: string, status: CapabilityLiveTurnResult["status"]): void {
|
||||
const existing = this.#terminalTurns.find((terminal) => terminal.turnId === turnId);
|
||||
if (existing !== undefined) {
|
||||
if (existing.status !== status) throw new Error("capability_live_terminal_fact_conflict");
|
||||
return;
|
||||
}
|
||||
this.#terminalTurns.push({
|
||||
turnId,
|
||||
status,
|
||||
reconciledByAttemptId: this.#currentAttemptId,
|
||||
observedAt: this.#now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
#rejectTurn(error: unknown): void {
|
||||
const waiter = this.#turnWaiter;
|
||||
if (waiter === null) return;
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createCapabilityRunnerdCodexTransport(
|
||||
const diagnostic = (message: string) => {
|
||||
evidence.diagnostics.push(message);
|
||||
if (evidence.diagnostics.length > 64) evidence.diagnostics.shift();
|
||||
const match = /capability codex proxy started runner_pid=(\d+) codex_pid=(\d+)/.exec(message);
|
||||
const match = /(?:capability|native) codex proxy started runner_pid=(\d+) codex_pid=(\d+)/.exec(message);
|
||||
if (match) {
|
||||
evidence.runnerPid = Number(match[1]);
|
||||
evidence.codexPid = Number(match[2]);
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./mock-core/capability-control-plane-types.js";
|
||||
export * from "./mock-core/capability-mock-control-plane-adapter.js";
|
||||
export * from "./protocol/conformance-fixture.js";
|
||||
export * from "./live/live-session.js";
|
||||
export * from "./live/durable-live-session-store.js";
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const statePath = process.argv[2];
|
||||
if (!statePath) throw new Error("state path is required");
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(statePath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
return {
|
||||
threadId: "thread-durable-runnerd",
|
||||
sessionId: "session-durable-runnerd",
|
||||
nextTurn: 0,
|
||||
heldOnce: false,
|
||||
turns: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let state = load();
|
||||
const pendingTools = new Map();
|
||||
let buffer = "";
|
||||
|
||||
function save() {
|
||||
writeFileSync(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function send(value) {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function finishTurn(turnId, toolResult) {
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: state.threadId,
|
||||
turnId,
|
||||
item: { id: `message-${turnId}`, type: "agentMessage", text: `Tool result: ${JSON.stringify(toolResult)}` },
|
||||
},
|
||||
});
|
||||
state.turns[turnId] = "completed";
|
||||
save();
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } },
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequest(message) {
|
||||
const { id, method, params = {} } = message;
|
||||
if (method === "initialize") {
|
||||
send({ id, result: { user: { sessionId: state.sessionId } } });
|
||||
return;
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
save();
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
model: "gpt-fixture",
|
||||
modelProvider: "openai-fixture",
|
||||
thread: { id: state.threadId, sessionId: state.sessionId },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "thread/read") {
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
thread: {
|
||||
id: state.threadId,
|
||||
sessionId: state.sessionId,
|
||||
turns: Object.entries(state.turns).map(([turnId, status]) => ({ id: turnId, status })),
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
model: "gpt-fixture",
|
||||
modelProvider: "openai-fixture",
|
||||
thread: { id: state.threadId, sessionId: state.sessionId },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
const turnId = `turn-${++state.nextTurn}`;
|
||||
state.turns[turnId] = "inProgress";
|
||||
save();
|
||||
send({ id, result: { turn: { id: turnId, status: "inProgress" } } });
|
||||
send({
|
||||
method: "turn/started",
|
||||
params: { threadId: state.threadId, turn: { id: turnId, status: "inProgress" } },
|
||||
});
|
||||
const toolRequestId = `tool-${turnId}`;
|
||||
pendingTools.set(toolRequestId, turnId);
|
||||
send({
|
||||
id: toolRequestId,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: state.threadId,
|
||||
turnId,
|
||||
callId: `governed-idempotent-call-${turnId}`,
|
||||
tool: "report_progress",
|
||||
arguments: {
|
||||
idempotencyKey: "governed-idempotency-key",
|
||||
body: "One durable governed effect.",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
const turnId = String(params.turnId);
|
||||
state.turns[turnId] = "interrupted";
|
||||
save();
|
||||
send({ id, result: {} });
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: state.threadId, turn: { id: turnId, status: "interrupted" } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
send({ id, error: { code: -32601, message: `unsupported method ${method}` } });
|
||||
}
|
||||
|
||||
function handle(message) {
|
||||
if (message.method) {
|
||||
if (message.id !== undefined) handleRequest(message);
|
||||
return;
|
||||
}
|
||||
const turnId = pendingTools.get(String(message.id));
|
||||
if (!turnId) return;
|
||||
pendingTools.delete(String(message.id));
|
||||
if (!state.heldOnce) {
|
||||
state.heldOnce = true;
|
||||
save();
|
||||
return;
|
||||
}
|
||||
finishTurn(turnId, message.result);
|
||||
}
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
for (;;) {
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) break;
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) {
|
||||
handle(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user