Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/keyed-exact-process-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tangle-network/agent-interface": minor
"@tangle-network/agent-provider-testkit": minor
---

feat(exact-process): define keyed retained-process recovery
27 changes: 25 additions & 2 deletions packages/agent-interface/src/environment-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AgentExactProcessStatus[]>;
/** Every supplied query field must match the returned process status exactly. */
list(query?: AgentExactProcessQuery): Promise<AgentExactProcessStatus[]>;
get(pid: number): Promise<AgentExactProcess | null>;
/** 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 },
Expand Down
36 changes: 34 additions & 2 deletions packages/agent-provider-testkit/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe("runAgentExactProcessProviderLifecycleChecks", () => {
cwd: "/tmp",
env: {},
timeoutMs: 1_000,
idempotencyKey: "execution-1-process",
retentionMs: 60_000,
},
expectedStdout: "ok",
expectedStderr: "",
Expand All @@ -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",
Expand Down Expand Up @@ -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 },
Expand All @@ -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;
},
Expand Down
65 changes: 65 additions & 0 deletions packages/agent-provider-testkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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),
Expand All @@ -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",
Expand Down
Loading