From 49519bd26e60bcaaab89fec1f126d742a89c563a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 21 Jul 2026 16:01:59 -0600 Subject: [PATCH] feat(exact-process): define retained process recovery --- .changeset/keyed-exact-process-recovery.md | 6 ++ .../src/environment-provider.ts | 27 +++++++- .../agent-provider-testkit/src/index.test.ts | 36 +++++++++- packages/agent-provider-testkit/src/index.ts | 65 +++++++++++++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .changeset/keyed-exact-process-recovery.md diff --git a/.changeset/keyed-exact-process-recovery.md b/.changeset/keyed-exact-process-recovery.md new file mode 100644 index 0000000..a08731d --- /dev/null +++ b/.changeset/keyed-exact-process-recovery.md @@ -0,0 +1,6 @@ +--- +"@tangle-network/agent-interface": minor +"@tangle-network/agent-provider-testkit": minor +--- + +feat(exact-process): define keyed retained-process recovery diff --git a/packages/agent-interface/src/environment-provider.ts b/packages/agent-interface/src/environment-provider.ts index dc7743a..20f46e9 100644 --- a/packages/agent-interface/src/environment-provider.ts +++ b/packages/agent-interface/src/environment-provider.ts @@ -97,6 +97,8 @@ export interface AgentExactProcessResources { /** Terminal or running state reported by an exact process host. */ export interface AgentExactProcessStatus { pid: number; + /** Opaque launch identity when the process was started with one. */ + idempotencyKey?: string; running: boolean; /** -1 while running; the exact process exit code after termination. */ exitCode: number; @@ -128,12 +130,33 @@ export interface AgentExactProcessLaunch { stdin?: string; /** Positive integer milliseconds, or zero to disable the process timeout. */ timeoutMs: number; + /** + * Opaque, non-secret launch identity. + * Repeating the same key and identical launch returns the same process while + * its terminal record is retained; a changed launch with the same key fails. + */ + idempotencyKey?: string; + /** + * Minimum terminal-record retention after exit. + * Valid only with {@link idempotencyKey}; providers fail before launch when + * they cannot honor it. + */ + retentionMs?: number; +} + +/** Select retained exact-process records by their stable launch identity. */ +export interface AgentExactProcessQuery { + idempotencyKey?: string; } export interface AgentExactProcessManager { - list(): Promise; + /** Every supplied query field must match the returned process status exactly. */ + list(query?: AgentExactProcessQuery): Promise; get(pid: number): Promise; - /** Providers must honor the abort signal when supplied. */ + /** + * Providers must honor the abort signal when supplied. + * A keyed launch returns a process whose status preserves that key. + */ spawn( input: AgentExactProcessLaunch, options?: { signal?: AbortSignal }, diff --git a/packages/agent-provider-testkit/src/index.test.ts b/packages/agent-provider-testkit/src/index.test.ts index 391fdb2..fdbdf24 100644 --- a/packages/agent-provider-testkit/src/index.test.ts +++ b/packages/agent-provider-testkit/src/index.test.ts @@ -40,6 +40,8 @@ describe("runAgentExactProcessProviderLifecycleChecks", () => { cwd: "/tmp", env: {}, timeoutMs: 1_000, + idempotencyKey: "execution-1-process", + retentionMs: 60_000, }, expectedStdout: "ok", expectedStderr: "", @@ -51,6 +53,7 @@ describe("runAgentExactProcessProviderLifecycleChecks", () => { "exact-process-idempotency-collision", "fresh-environment", "exact-file-roundtrip", + "exact-process-launch-idempotency", "exact-process-run", "exact-process-recovery", "exact-process-list", @@ -138,9 +141,11 @@ function fakeExactProcessProvider(): AgentEnvironmentProvider { } let deleted = false; let spawned = false; + let launchFingerprint: string | undefined; const output = "ok"; const status = { pid: 41, + idempotencyKey: undefined as string | undefined, running: false, exitCode: 0, termination: { kind: "exit" as const, exitCode: 0 }, @@ -160,9 +165,36 @@ function fakeExactProcessProvider(): AgentEnvironmentProvider { provider: "fake", metadata: input.metadata, process: { - list: async () => (deleted || !spawned ? [] : [status]), + list: async (query) => + deleted || + !spawned || + (query?.idempotencyKey !== undefined && + query.idempotencyKey !== status.idempotencyKey) + ? [] + : [status], get: async (pid) => (!deleted && spawned && pid === process.pid ? process : null), - spawn: async () => { + spawn: async (launch) => { + if (!launch.idempotencyKey || !launch.retentionMs) { + throw new Error("missing exact process recovery fields"); + } + const fingerprint = JSON.stringify({ + executable: launch.executable, + args: launch.args, + cwd: launch.cwd, + env: launch.env, + stdin: launch.stdin, + timeoutMs: launch.timeoutMs, + idempotencyKey: launch.idempotencyKey, + retentionMs: launch.retentionMs, + }); + if ( + launchFingerprint !== undefined && + launchFingerprint !== fingerprint + ) { + throw new Error("process idempotency collision"); + } + launchFingerprint = fingerprint; + status.idempotencyKey = launch.idempotencyKey; spawned = true; return process; }, diff --git a/packages/agent-provider-testkit/src/index.ts b/packages/agent-provider-testkit/src/index.ts index a7075bb..431c708 100644 --- a/packages/agent-provider-testkit/src/index.ts +++ b/packages/agent-provider-testkit/src/index.ts @@ -141,6 +141,21 @@ export async function runAgentExactProcessProviderLifecycleChecks( ); checked.push("exact-process-capability"); + const processKey = options.launch.idempotencyKey; + assert( + typeof processKey === "string" && processKey.trim().length > 0, + "exact process lifecycle recovery requires launch.idempotencyKey", + checked, + ); + const retentionMs = options.launch.retentionMs; + assert( + typeof retentionMs === "number" && + Number.isSafeInteger(retentionMs) && + retentionMs > 0, + "exact process lifecycle recovery requires a positive launch.retentionMs", + checked, + ); + const operation = new AbortController(); const timeout = setTimeout( () => operation.abort(new Error("exact process lifecycle check timed out")), @@ -244,11 +259,52 @@ export async function runAgentExactProcessProviderLifecycleChecks( "spawned exact process must appear in process.list()", checked, ); + const keyed = await environment.process.list({ idempotencyKey: processKey }); + assert( + keyed.length === 1 && keyed[0]?.pid === process.pid, + "exact process lookup by idempotency key must return only the launched process", + checked, + ); + assert( + keyed[0]?.idempotencyKey === processKey, + "exact process lookup must preserve the idempotency key", + checked, + ); + const repeatedProcess = await environment.process.spawn(options.launch, { + signal, + }); + assert( + repeatedProcess.pid === process.pid, + "repeated exact process launch must return the existing process", + checked, + ); + let launchCollisionRejected = false; + try { + await environment.process.spawn( + { + ...options.launch, + args: [...options.launch.args, "--different-launch"], + }, + { signal }, + ); + } catch { + launchCollisionRejected = true; + } + assert( + launchCollisionRejected, + "reusing a process idempotency key with a different launch must fail", + checked, + ); const stdout = (await collect(process.stdout())).join(""); const stderr = (await collect(process.stderr())).join(""); const termination = await abortable(process.wait(), signal); const status = await process.status(); assert(!status.running, "exact process must reach a terminal status", checked); + assert( + status.idempotencyKey === processKey, + "terminal exact process status must preserve its idempotency key", + checked, + ); assert(status.termination, "terminal exact process status requires a reason", checked); assert( terminationEqual(status.termination, termination), @@ -258,12 +314,21 @@ export async function runAgentExactProcessProviderLifecycleChecks( assert(stdout === options.expectedStdout, "exact process stdout differs", checked); assert(stderr === options.expectedStderr, "exact process stderr differs", checked); await process.kill(); + checked.push("exact-process-launch-idempotency"); checked.push("exact-process-run"); const recovered = await provider.exactProcess.get(environment.id); assert(recovered, "exact environment must be recoverable by id", checked); const recoveredProcess = await recovered.process.get(process.pid); assert(recoveredProcess, "exact process must be recoverable by pid", checked); + const recoveredByKey = await recovered.process.list({ + idempotencyKey: processKey, + }); + assert( + recoveredByKey.length === 1 && recoveredByKey[0]?.pid === process.pid, + "recovered environment must retain its exact process lookup by idempotency key", + checked, + ); assert( (await collect(recoveredProcess.stdout())).join("") === options.expectedStdout, "recovered exact process stdout differs",