mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Retain unchanged agent files with their warm native owner
Probe complete materializations in bounded children, atomically transfer current-run collection authority, and preserve the exact agent home across warm turns. Retire before collecting changed or uncertain copies, fence stale claims and cleanup, and retain unresolved retirement ownership. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d21d375de
commit
6c6c05dccb
8 files changed
+736
-50
No files matched your search
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import childProcess, { execFileSync } from "node:child_process";
|
||||
import { captureDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory, probeAgentDirectory } from "../services/agent-directory-probe.js";
|
||||
|
||||
describe("stable live agent directory observation", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "agent-directory-probe-")));
|
||||
fs.mkdirSync(join(root, "notes"));
|
||||
fs.writeFileSync(join(root, "AGENTS.md"), "Instructions\n");
|
||||
fs.writeFileSync(join(root, "notes", "memory.bin"), Buffer.from([0, 1, 255]));
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
it("matches the complete materialized baseline and the actual remote Node program", async () => {
|
||||
const baseline = await captureDirectorySnapshot(root);
|
||||
const observed = probeAgentDirectory(root);
|
||||
expect(observed.digest).toBe(agentDirectoryBaselineDigest(baseline));
|
||||
const remote = JSON.parse(execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram, root], { encoding: "utf8", env: {}, timeout: 10_000 }));
|
||||
expect(remote).toEqual(observed);
|
||||
});
|
||||
it("runs the local observation in its bounded credential-free child", async () => {
|
||||
expect(await observeLocalAgentDirectory(root)).toEqual(probeAgentDirectory(root));
|
||||
});
|
||||
it.each(["timeout", "oversized", "malformed"])("rejects %s local-child observations", async kind => {
|
||||
const stub = vi.spyOn(childProcess, "execFile").mockImplementation(((file: string, args: string[], options: object, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
expect(file).toBe(process.execPath);
|
||||
expect(args).toEqual(["-e", agentDirectoryProbeProgram, root]);
|
||||
expect(options).toMatchObject({ cwd: root, env: {}, timeout: 10_000, maxBuffer: 1024, killSignal: "SIGKILL" });
|
||||
callback(kind === "timeout" ? Object.assign(new Error("timed out"), { killed: true, signal: "SIGKILL" }) : null,
|
||||
kind === "oversized" ? "x".repeat(1025) : "{}", "");
|
||||
return {} as childProcess.ChildProcess;
|
||||
}) as typeof childProcess.execFile);
|
||||
await expect(observeLocalAgentDirectory(root)).rejects.toThrow();
|
||||
expect(stub).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each(["edit", "add", "remove", "mode"])("detects a legitimate %s", change => {
|
||||
const before = probeAgentDirectory(root);
|
||||
if (change === "edit") fs.writeFileSync(join(root, "AGENTS.md"), "New instructions\n");
|
||||
if (change === "add") fs.writeFileSync(join(root, "new.txt"), "New");
|
||||
if (change === "remove") fs.unlinkSync(join(root, "notes", "memory.bin"));
|
||||
if (change === "mode") fs.chmodSync(join(root, "AGENTS.md"), 0o700);
|
||||
expect(probeAgentDirectory(root).digest).not.toBe(before.digest);
|
||||
});
|
||||
it.each(["symlink", "hardlink", "reserved"])("rejects %s entries", kind => {
|
||||
if (kind === "symlink") fs.symlinkSync(join(root, "AGENTS.md"), join(root, "unsafe"));
|
||||
if (kind === "hardlink") fs.linkSync(join(root, "AGENTS.md"), join(root, "unsafe"));
|
||||
if (kind === "reserved") fs.mkdirSync(join(root, ".paperclip-runtime"));
|
||||
expect(() => probeAgentDirectory(root)).toThrow();
|
||||
});
|
||||
it("detects file mutation during a bounded read", () => {
|
||||
const read = fs.readSync;
|
||||
let changed = false;
|
||||
vi.spyOn(fs, "readSync").mockImplementation(((...args: Parameters<typeof read>) => {
|
||||
const result = Reflect.apply(read, fs, args);
|
||||
if (!changed) { changed = true; fs.appendFileSync(join(root, "AGENTS.md"), "Concurrent change"); }
|
||||
return result;
|
||||
}) as typeof read);
|
||||
expect(() => probeAgentDirectory(root)).toThrow(/changed|grew/);
|
||||
});
|
||||
it("fails on unreadable observations instead of reporting unchanged", () => {
|
||||
vi.spyOn(fs, "openSync").mockImplementation(() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); });
|
||||
expect(() => probeAgentDirectory(root)).toThrow("denied");
|
||||
});
|
||||
it("does not let matching replacement bytes preserve the pinned root identity", () => {
|
||||
const before = probeAgentDirectory(root);
|
||||
const retired = `${root}-retired`;
|
||||
fs.renameSync(root, retired);
|
||||
try { fs.cpSync(retired, root, { recursive: true }); expect(probeAgentDirectory(root).identity).not.toBe(before.identity); }
|
||||
finally { fs.rmSync(retired, { recursive: true, force: true }); }
|
||||
});
|
||||
it("rejects excluded/incomplete baselines", async () => {
|
||||
const baseline = await captureDirectorySnapshot(root);
|
||||
expect(() => agentDirectoryBaselineDigest({ ...baseline, exclude: ["notes"] })).toThrow("Incomplete");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
|
||||
import { agentFileStore, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
|
||||
import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, environmentLeases, environments, agentInstructionWorkingCopies, agentInstructionRevisions, agentInstructionHeads, createDb } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentDirectoryWorkingCopyService } from "../services/agent-directory-working-copies.js";
|
||||
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
||||
import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "../services/agent-instruction-working-copies.js";
|
||||
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
||||
@@ -71,6 +72,161 @@ describe("persistent agent directories", () => {
|
||||
await fs.writeFile(path.join(root, entryFile), initial);
|
||||
});
|
||||
|
||||
describe("warm directory ownership", () => {
|
||||
async function nextRun() {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
||||
return runId;
|
||||
}
|
||||
it("adopts one physical root and fences stale cleanup through final save", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
const second = await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home });
|
||||
expect(second).toMatchObject({ runId: secondId, localRoot: first.localRoot, executionRoot: first.executionRoot,
|
||||
responsibleUserId: userId, receipt: { materializationRunId: first.runId } });
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBe(secondId);
|
||||
await copies.release(companyId, first.runId);
|
||||
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.processStoppedAt).toBeNull();
|
||||
await copies.reportUnavailable(companyId, first.runId);
|
||||
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
await copies.release(companyId, secondId);
|
||||
expect(await copies.hasChanges({ companyId, runId: secondId })).toBe(false);
|
||||
const thirdId = await nextRun();
|
||||
const third = await copies.adopt({ companyId, agentId, runId: thirdId, previousRunId: secondId, cwd: home });
|
||||
expect(third?.executionRoot).toBe(first.executionRoot);
|
||||
await fs.writeFile(path.join(first.localRoot, "warm-memory.txt"), "third turn edit");
|
||||
expect(await copies.hasChanges({ companyId, runId: thirdId })).toBe(true);
|
||||
expect(await copies.collectStopped({ companyId, runId: thirdId })).toMatchObject({ state: "saved", receipt: { cleanupPending: false } });
|
||||
expect(await fs.readFile(path.join(root, "warm-memory.txt"), "utf8")).toBe("third turn edit");
|
||||
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await copies.release(companyId, first.runId);
|
||||
});
|
||||
it.each(["company", "agent", "workspace", "environment"])("rejects foreign %s adoption", async mismatch => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
expect(await copies.adopt({ companyId: mismatch === "company" ? randomUUID() : companyId,
|
||||
agentId: mismatch === "agent" ? randomUUID() : agentId, runId: secondId, previousRunId: first.runId,
|
||||
cwd: mismatch === "workspace" ? `${home}-other` : home,
|
||||
...(mismatch === "environment" ? { target: { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd: "/workspace" } } : {}),
|
||||
})).toBeNull();
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined();
|
||||
await copies.collectStopped({ companyId, runId: first.runId });
|
||||
});
|
||||
it("allows only one stale concurrent adoption with identical updatedAt timestamps", async () => {
|
||||
const fixed = new Date("2030-01-01T00:00:00.000Z");
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(fixed);
|
||||
try {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const next = await Promise.all([nextRun(), nextRun()]);
|
||||
let reads = 0;
|
||||
let releaseReads!: () => void;
|
||||
const bothRead = new Promise<void>(resolve => { releaseReads = resolve; });
|
||||
// Both claimants see the same unclaimed row before either can enter
|
||||
// the agent-row transaction. Date precision cannot distinguish them.
|
||||
const staleReader = async () => {
|
||||
const captured = await copies.get(companyId, first.runId);
|
||||
reads++;
|
||||
if (reads >= 3) { if (reads === 4) releaseReads(); await bothRead; }
|
||||
return captured;
|
||||
};
|
||||
const service = agentDirectoryWorkingCopyService(db, staleReader, async (row, values) => {
|
||||
const [updated] = await db.update(agentInstructionWorkingCopies).set({ ...values, updatedAt: fixed })
|
||||
.where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning();
|
||||
return updated;
|
||||
});
|
||||
const settled = await Promise.allSettled(next.map(runId => service.adopt({ companyId, agentId, runId, previousRunId: first.runId, cwd: home })));
|
||||
const winners = settled.filter(result => result.status === "fulfilled" && result.value);
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(settled.filter(result => result.status === "rejected")).toHaveLength(1);
|
||||
const prior = await copies.get(companyId, first.runId);
|
||||
expect(prior?.updatedAt).toEqual(fixed);
|
||||
const winnerId = prior?.receipt?.retainedByRunId as string;
|
||||
expect(next).toContain(winnerId);
|
||||
expect(await copies.get(companyId, next.find(id => id !== winnerId)!)).toBeNull();
|
||||
await copies.collectStopped({ companyId, runId: winnerId });
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
it("fences a stale collector when adoption commits immediately before its stop CAS", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
const update = db.update.bind(db);
|
||||
let injected = false;
|
||||
const spy = vi.spyOn(db, "update").mockImplementation(table => {
|
||||
const builder = update(table);
|
||||
if (table !== agentInstructionWorkingCopies) return builder;
|
||||
const set = builder.set.bind(builder);
|
||||
builder.set = (values => {
|
||||
const query = set(values);
|
||||
if (!("processStoppedAt" in values) || !values.processStoppedAt || injected) return query;
|
||||
const returning = query.returning.bind(query);
|
||||
query.returning = (async () => {
|
||||
injected = true;
|
||||
expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).not.toBeNull();
|
||||
return returning();
|
||||
}) as typeof query.returning;
|
||||
return query;
|
||||
}) as typeof builder.set;
|
||||
return builder;
|
||||
});
|
||||
try {
|
||||
expect(await copies.collectStopped({ companyId, runId: first.runId })).toMatchObject({
|
||||
receipt: { retainedByRunId: secondId }, processStoppedAt: null, attempts: 0,
|
||||
});
|
||||
expect(injected).toBe(true);
|
||||
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
expect((await copies.get(companyId, secondId))?.processStoppedAt).toBeNull();
|
||||
} finally { spy.mockRestore(); }
|
||||
await copies.collectStopped({ companyId, runId: secondId });
|
||||
});
|
||||
it("requires current-run authorization before transferring collection authority", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, secondId));
|
||||
await expect(copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).rejects.toMatchObject({ status: 403 });
|
||||
expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined();
|
||||
await copies.collectStopped({ companyId, runId: first.runId });
|
||||
});
|
||||
it("durably preserves an adopted directory after retirement failure", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
const secondId = await nextRun();
|
||||
await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home });
|
||||
await db.update(heartbeatRuns).set({ status: "failed", runtimeMode: "native" }).where(eq(heartbeatRuns.id, secondId));
|
||||
expect(await copies.reportRetirementUnconfirmed(companyId, secondId)).toMatchObject({
|
||||
state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", processStoppedAt: null,
|
||||
});
|
||||
await copies.reportRetirementUnconfirmed(companyId, first.runId);
|
||||
await copies.release(companyId, first.runId);
|
||||
await copies.release(companyId, secondId);
|
||||
await copies.recoverStopped(); // A terminal run with no stop receipt grants no collection authority.
|
||||
expect((await copies.get(companyId, secondId))?.processStoppedAt).toBeNull();
|
||||
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
||||
await copies.collectStopped({ companyId, runId: secondId }); // Explicit test owner retirement.
|
||||
});
|
||||
it("declines reuse after canonical instruction changes", async () => {
|
||||
const first = await run();
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
|
||||
await fs.writeFile(path.join(root, entryFile), "Changed canonical instructions");
|
||||
const secondId = await nextRun();
|
||||
expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).toBeNull();
|
||||
await copies.collectStopped({ companyId, runId: first.runId });
|
||||
});
|
||||
it("requires stop for matching bytes at a replaced materialized root", async () => {
|
||||
const first = await run();
|
||||
const replaced = `${first.localRoot}-old`;
|
||||
await fs.rename(first.localRoot, replaced);
|
||||
await fs.cp(replaced, first.localRoot, { recursive: true });
|
||||
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(true);
|
||||
await copies.collectStopped({ companyId, runId: first.runId });
|
||||
});
|
||||
});
|
||||
|
||||
it.each([".paperclip-runtime/state", "notes/.paperclip-runtime/state", "promptTemplate.legacy.md"])("rejects reserved board path %s before mutation", async (reserved) => {
|
||||
await expect(agentFileStore(db).write({ ...target(), path: reserved, bytes: Buffer.from("reserved"), baseHash: null }, board())).rejects.toMatchObject({ status: 422 });
|
||||
await expect(fs.stat(path.join(root, reserved))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import childProcess from "node:child_process";
|
||||
import type { DirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
|
||||
/** Only for a live unchanged-turn observation. Never a save or stop receipt. */
|
||||
export function agentDirectoryBaselineDigest(snapshot: DirectorySnapshot): string {
|
||||
if (snapshot.exclude.length || snapshot.ignoredPaths) throw new Error("Incomplete agent directory baseline");
|
||||
const entries = [...snapshot.entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
||||
}
|
||||
|
||||
// This self-contained function also runs inside the original remote target.
|
||||
// Keep all dependencies explicit so remote observation never reads a local mirror.
|
||||
export function probeAgentDirectory(
|
||||
root: string,
|
||||
modules = { fs, path, createHash },
|
||||
): { digest: string; identity: string } {
|
||||
const { fs, path, createHash } = modules;
|
||||
const deadline = Date.now() + 5_000;
|
||||
const checkTime = () => { if (Date.now() > deadline) throw new Error("Agent directory probe deadline"); };
|
||||
const stamp = (s: fs.BigIntStats) => [s.dev, s.ino, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].join(":");
|
||||
const identity = (s: fs.BigIntStats) => [s.dev, s.ino, s.birthtimeNs].join(":");
|
||||
if (!path.isAbsolute(root) || path.resolve(root) !== root || root.includes("\0")) throw new Error("Invalid agent directory root");
|
||||
// Fixed macOS system aliases are resolved by the caller, not followed here.
|
||||
const ancestors = new Map<string, string>();
|
||||
let ancestor = path.parse(root).root;
|
||||
for (const segment of root.slice(ancestor.length).split(path.sep)) {
|
||||
ancestor = path.join(ancestor, segment);
|
||||
const s = fs.lstatSync(ancestor, { bigint: true });
|
||||
if (!s.isDirectory() || s.isSymbolicLink()) throw new Error("Unsafe agent directory ancestor");
|
||||
ancestors.set(ancestor, identity(s));
|
||||
}
|
||||
const metadata = new Map<string, string>();
|
||||
const entries: Array<[string, { kind: "dir" } | { kind: "file"; mode: number; hash: string }]> = [];
|
||||
let total = 0;
|
||||
function walk(relative: string, verify: boolean) {
|
||||
checkTime();
|
||||
const absolute = path.join(root, relative);
|
||||
const before = fs.lstatSync(absolute, { bigint: true });
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("Unsafe agent directory");
|
||||
const names = fs.readdirSync(absolute).sort();
|
||||
if (verify) {
|
||||
if (metadata.get(relative) !== stamp(before)) throw new Error("Agent directory changed during probe");
|
||||
} else metadata.set(relative, stamp(before));
|
||||
for (const name of names) {
|
||||
checkTime();
|
||||
const next = relative ? `${relative}/${name}` : name;
|
||||
if (next.length > 512 || name === "." || name === ".." || name.includes("\\") || name.includes("\0")
|
||||
|| name === ".paperclip-runtime" || next === "promptTemplate.legacy.md") throw new Error("Unsafe agent file path");
|
||||
const filename = path.join(root, next);
|
||||
const s = fs.lstatSync(filename, { bigint: true });
|
||||
if (s.isDirectory()) {
|
||||
if (!verify) entries.push([next, { kind: "dir" }]);
|
||||
walk(next, verify);
|
||||
} else {
|
||||
if (!s.isFile() || s.nlink !== 1n || s.size > 256n * 1024n * 1024n) throw new Error("Unsafe or oversized agent file");
|
||||
if (verify) {
|
||||
if (metadata.get(next) !== stamp(s)) throw new Error("Agent file changed during probe");
|
||||
} else {
|
||||
total += Number(s.size);
|
||||
if (total > 2 * 1024 * 1024 * 1024) throw new Error("Agent directory probe byte limit");
|
||||
const fd = fs.openSync(filename, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
try {
|
||||
if (stamp(fs.fstatSync(fd, { bigint: true })) !== stamp(s)) throw new Error("Agent file replaced during open");
|
||||
const hash = createHash("sha256"), buffer = Buffer.alloc(64 * 1024);
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
checkTime();
|
||||
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (!count) break;
|
||||
size += count;
|
||||
if (size > Number(s.size)) throw new Error("Agent file grew during probe");
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
if (size !== Number(s.size) || stamp(fs.fstatSync(fd, { bigint: true })) !== stamp(s)
|
||||
|| stamp(fs.lstatSync(filename, { bigint: true })) !== stamp(s)) throw new Error("Agent file changed during read");
|
||||
metadata.set(next, stamp(s));
|
||||
entries.push([next, { kind: "file", mode: Number(s.mode), hash: hash.digest("hex") }]);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
}
|
||||
if (entries.length > 100_000) throw new Error("Agent directory probe entry limit");
|
||||
}
|
||||
if (stamp(fs.lstatSync(absolute, { bigint: true })) !== stamp(before)
|
||||
|| JSON.stringify(fs.readdirSync(absolute).sort()) !== JSON.stringify(names)) throw new Error("Agent directory membership changed");
|
||||
}
|
||||
const initial = fs.lstatSync(root, { bigint: true });
|
||||
walk("", false);
|
||||
walk("", true);
|
||||
if (stamp(fs.lstatSync(root, { bigint: true })) !== stamp(initial)) throw new Error("Agent directory root changed");
|
||||
for (const [ancestor, expected] of ancestors) {
|
||||
const current = fs.lstatSync(ancestor, { bigint: true });
|
||||
if (!current.isDirectory() || current.isSymbolicLink() || identity(current) !== expected) throw new Error("Agent directory ancestor changed");
|
||||
}
|
||||
entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
return { digest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"), identity: identity(initial) };
|
||||
}
|
||||
|
||||
export const agentDirectoryProbeProgram = `const probe=${probeAgentDirectory.toString()}; process.stdout.write(JSON.stringify(probe(process.argv[1], {fs:require('node:fs'),path:require('node:path'),createHash:require('node:crypto').createHash})))`;
|
||||
|
||||
/** Bound child: no shell, inherited credentials, or event-loop hashing. */
|
||||
export async function observeLocalAgentDirectory(root: string) {
|
||||
const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
childProcess.execFile(process.execPath, ["-e", agentDirectoryProbeProgram, root], {
|
||||
cwd: root, env: {}, timeout: 10_000, maxBuffer: 1024, killSignal: "SIGKILL", encoding: "utf8",
|
||||
}, (error, stdout, stderr) => { if (error) reject(error); else resolve({ stdout, stderr }); });
|
||||
});
|
||||
if (result.stdout.length > 1024) throw new Error("Agent directory observation exceeds bound");
|
||||
const value = JSON.parse(result.stdout);
|
||||
if (!value || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)
|
||||
|| typeof value.identity !== "string" || !/^[0-9]+:[0-9]+:[0-9]+$/.test(value.identity)) throw new Error("Invalid agent directory observation");
|
||||
return value as { digest: string; identity: string };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { agents, environmentLeases, environments, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db";
|
||||
import { syncDirectoryToSsh, restoreWorkspaceFromSshExecution } from "@paperclipai/adapter-utils/ssh";
|
||||
import { prepareAdapterExecutionTargetRuntime, runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget, type PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -13,6 +13,7 @@ import { HttpError, conflict, notFound } from "../errors.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
|
||||
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
|
||||
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js";
|
||||
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
|
||||
type Copy = typeof copies.$inferSelect;
|
||||
@@ -50,21 +51,43 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all",
|
||||
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
|
||||
}
|
||||
async function observe(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
if (row.location === "local") {
|
||||
const real = await fs.realpath(row.localRoot);
|
||||
if (real !== row.localRoot && !(process.platform === "darwin" && real === `/private${row.localRoot}` && /^\/(tmp|var|etc)\//.test(row.localRoot))) throw new Error("Agent directory root changed");
|
||||
// A bounded child keeps hashing off the controller event loop and inherits
|
||||
// no provider credentials. execFile terminates it on timeout.
|
||||
return observeLocalAgentDirectory(real);
|
||||
}
|
||||
if (!target || target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed");
|
||||
const origin = typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : row.runId;
|
||||
if (row.executionRoot !== path.posix.join(target.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, origin)) throw new Error("Agent directory root changed");
|
||||
const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
const result = await runAdapterExecutionTargetShellCommand(row.runId, target,
|
||||
`node -e ${quote(agentDirectoryProbeProgram)} ${quote(row.executionRoot)}`, { cwd: target.remoteCwd, env: {}, timeoutSec: 10 });
|
||||
if (result.exitCode !== 0 || result.timedOut || result.stdout.length > 1024) throw new Error("Agent directory observation unavailable");
|
||||
const value = JSON.parse(result.stdout);
|
||||
if (!value || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) || typeof value.identity !== "string") throw new Error("Invalid agent directory observation");
|
||||
return value as { digest: string; identity: string };
|
||||
}
|
||||
async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string }) {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (agentInstructionsBundleMode(agent) !== "managed") return null;
|
||||
const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId });
|
||||
const root = resolveManagedInstructionsRoot(agent);
|
||||
const localRoot = path.join(path.dirname(root), "file-sync", "runs", input.runId, "live");
|
||||
let row = await get(input.companyId, input.runId);
|
||||
if (row?.receipt?.retainedByRunId) throw conflict("Agent directory ownership passed to another run");
|
||||
const origin = typeof row?.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : input.runId;
|
||||
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(origin)) throw conflict("Invalid agent directory owner");
|
||||
const localRoot = path.join(path.dirname(root), "file-sync", "runs", origin, "live");
|
||||
// Local copies live outside the task cwd. Remote copies use the reserved,
|
||||
// excluded runtime area inside the provider's confined workspace. They are
|
||||
// synchronized independently; provider HOME is never reinterpreted.
|
||||
const executionRoot = input.target?.kind === "remote"
|
||||
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, input.runId)
|
||||
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, origin)
|
||||
: localRoot;
|
||||
const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local";
|
||||
let row = await get(input.companyId, input.runId);
|
||||
if (row && (row.localRoot !== localRoot || row.executionRoot !== executionRoot || row.agentId !== input.agentId || row.location !== location)) throw conflict("Agent directory belongs to a different execution environment");
|
||||
if (row && !completed.has(row.state) && row.state !== "preparing") {
|
||||
if (input.target?.kind === "remote" && !transports.has(key(row))) transports.set(key(row), await transport(row, input.target, true));
|
||||
@@ -74,7 +97,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
// leaves a recoverable preparing receipt instead of an orphan directory.
|
||||
const preparing = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: "preparing",
|
||||
localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null,
|
||||
receipt: { schema: AGENT_FILES_CONTRACT, ...(input.target?.kind === "remote"
|
||||
receipt: { schema: AGENT_FILES_CONTRACT, workspaceCwd: input.cwd, materializationRunId: origin, ...(input.target?.kind === "remote"
|
||||
? { cleanup: { leaseId: input.target.leaseId ?? null, remoteCwd: input.target.remoteCwd } } : {}) }, processStoppedAt: null, attempts: 0,
|
||||
nextAttemptAt: null, errorCode: null, errorMessage: null };
|
||||
if (row) row = await patch(row, preparing);
|
||||
@@ -121,7 +144,9 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude agent files from task Git staging");
|
||||
transports.set(key(row), await transport(row, input.target, false));
|
||||
}
|
||||
return await patch(row, { state: "prepared" });
|
||||
const observed = await observe(row, input.target).catch(() => null);
|
||||
return await patch(row, { state: "prepared", receipt: { ...row.receipt,
|
||||
...(observed?.digest === agentDirectoryBaselineDigest(snapshot) ? { materializationIdentity: observed.identity } : {}) } });
|
||||
} catch (error) {
|
||||
// Preparation failed before a provider could start using this copy.
|
||||
row = await patch(row, { state: "unavailable", processStoppedAt: new Date(), errorCode: "AGENT_FILES_PREPARE_FAILED",
|
||||
@@ -143,16 +168,51 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
}
|
||||
return inspectAgentDirectory(row.localRoot);
|
||||
}
|
||||
async function hasChanges(_row: Copy, _target?: AdapterExecutionTarget | null) {
|
||||
// A whole-directory collector needs a quiescent provider, including its
|
||||
// child processes. Checkpoint and close at the turn boundary before reading
|
||||
// either local or remote files. The provider conversation remains resumable;
|
||||
// ordinary file edits do not change the session configuration digest.
|
||||
return true;
|
||||
async function hasChanges(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
try {
|
||||
if (row.receipt?.retainedByRunId || typeof row.receipt?.materializationIdentity !== "string") return true;
|
||||
const observed = await observe(row, target);
|
||||
if (observed.identity !== row.receipt.materializationIdentity || observed.digest !== agentDirectoryBaselineDigest(baseline(row))) return true;
|
||||
const unchanged = await patch(row, { state: "unchanged_turn", nextAttemptAt: null });
|
||||
return unchanged.state !== "unchanged_turn" || Boolean(unchanged.receipt?.retainedByRunId) || Boolean(unchanged.processStoppedAt);
|
||||
} catch { return true; }
|
||||
}
|
||||
/** Rebind one physical materialization; earlier runs lose collection authority. */
|
||||
async function adopt(input: { companyId: string; agentId: string; runId: string; previousRunId: string; target?: AdapterExecutionTarget | null; cwd: string }) {
|
||||
let prior = await get(input.companyId, input.previousRunId);
|
||||
if (!prior || prior.agentId !== input.agentId || prior.state !== "unchanged_turn" || prior.receipt?.retainedByRunId || prior.processStoppedAt
|
||||
|| prior.receipt?.workspaceCwd !== input.cwd || prior.location !== (input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local")) return null;
|
||||
if (await hasChanges(prior, input.target)) return null;
|
||||
prior = (await get(input.companyId, input.previousRunId))!;
|
||||
const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId });
|
||||
const adopted = await store.locked(input.companyId, input.agentId, bound, false, async (tx, _agent, canonical) => {
|
||||
const current = await inspectAgentDirectory(canonical);
|
||||
if (agentDirectoryBaselineDigest(current.snapshot) !== agentDirectoryBaselineDigest(baseline(prior))) return null;
|
||||
const [claimed] = await tx.update(copies).set({ receipt: { ...prior.receipt, retainedByRunId: input.runId }, updatedAt: new Date() })
|
||||
.where(and(eq(copies.companyId, input.companyId), eq(copies.runId, prior.runId), eq(copies.agentId, input.agentId), eq(copies.state, "unchanged_turn"),
|
||||
eq(copies.updatedAt, prior.updatedAt), eq(copies.baseHash, prior.baseHash), eq(copies.localRoot, prior.localRoot),
|
||||
isNull(copies.processStoppedAt), sql`${copies.receipt}->>'retainedByRunId' IS NULL`)).returning();
|
||||
if (!claimed) throw conflict("Agent directory ownership changed");
|
||||
const { createdAt: _createdAt, updatedAt: _updatedAt, ...values } = prior;
|
||||
const [next] = await tx.insert(copies).values({ ...values, runId: input.runId, responsibleUserId: bound.onBehalfOfUserId!, state: "prepared",
|
||||
receipt: { ...prior.receipt, materializationRunId: prior.receipt?.materializationRunId ?? prior.runId }, updatedAt: new Date(),
|
||||
}).returning();
|
||||
return next;
|
||||
});
|
||||
// Do not move the live transport until the database ownership commit succeeds.
|
||||
if (adopted) {
|
||||
const runtime = transports.get(key(prior));
|
||||
if (runtime) { transports.delete(key(prior)); transports.set(key(adopted), runtime); }
|
||||
}
|
||||
return adopted;
|
||||
}
|
||||
async function collectStopped(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
if (row.receipt?.retainedByRunId) return row;
|
||||
if (completed.has(row.state)) { await release(row); return (await get(row.companyId, row.runId))!; }
|
||||
row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date() });
|
||||
// Adoption may win between the initial read and the stop CAS. The returned
|
||||
// current row is authoritative; a transferred alias cannot inspect or save.
|
||||
if (row.receipt?.retainedByRunId || !row.processStoppedAt) return row;
|
||||
// The stopped working copy is the only temporary tree. Retry transient I/O
|
||||
// at this boundary, then clean it up; there are no preserved run snapshots.
|
||||
let inspected: Awaited<ReturnType<typeof retrieve>> | undefined;
|
||||
@@ -188,6 +248,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
return (await get(row.companyId, row.runId))!;
|
||||
}
|
||||
async function release(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
if (row.receipt?.retainedByRunId || !row.processStoppedAt) return;
|
||||
// Collection and environment teardown can both release the same copy.
|
||||
// A compact successful cleanup receipt is final, even after restart.
|
||||
if (completed.has(row.state) && row.processStoppedAt && row.receipt?.cleanupPending === false) return;
|
||||
@@ -197,7 +258,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
await runtime?.cleanupWorkspaceSnapshot?.().catch(() => { cleanupPending = true; });
|
||||
const cleanupTarget = runtime?.target ?? target;
|
||||
if (row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") {
|
||||
const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId);
|
||||
const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : row.runId);
|
||||
if (row.executionRoot !== expected) throw new Error("Agent directory cleanup path changed");
|
||||
const quoted = `'${expected.replaceAll("'", `'"'"'`)}'`;
|
||||
const remoteCleanupFailed = await runAdapterExecutionTargetShellCommand(row.runId, cleanupTarget, `rm -rf -- ${quoted}`,
|
||||
@@ -237,7 +298,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId));
|
||||
if (!environment) return false;
|
||||
const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd;
|
||||
if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId)) return false;
|
||||
if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : row.runId)) return false;
|
||||
const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", row.executionRoot],
|
||||
cwd: remoteCwd, env: {}, timeoutMs: 15_000, bypassSession: true });
|
||||
return result.exitCode === 0 && !result.timedOut;
|
||||
@@ -251,7 +312,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
return fn(current);
|
||||
});
|
||||
}
|
||||
return { prepare, hasChanges,
|
||||
return { prepare, hasChanges, adopt,
|
||||
collectStopped: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => collectStopped(current, target)),
|
||||
release: (row: Copy) => serial(row, release),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
|
||||
async function patch(row: Copy, values: Partial<typeof copies.$inferInsert>) {
|
||||
const [updated] = await db.update(copies).set({ ...values, updatedAt: new Date() }).where(and(
|
||||
scope(row.companyId, row.runId), eq(copies.state, row.state), eq(copies.attempts, row.attempts), eq(copies.baseHash, row.baseHash),
|
||||
sql`${copies.receipt}->>'retainedByRunId' IS NOT DISTINCT FROM ${typeof row.receipt?.retainedByRunId === "string" ? row.receipt.retainedByRunId : null}`,
|
||||
)).returning();
|
||||
// A late cleanup must not overwrite an explicit resolution or a newer
|
||||
// baseline. Canonical content has its own independent head CAS.
|
||||
@@ -294,8 +295,9 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
|
||||
async function recoverStopped() {
|
||||
const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies)
|
||||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
|
||||
.where(and(or(inArray(copies.state, ["prepared", "pending_collection"]),
|
||||
.where(and(or(inArray(copies.state, ["prepared", "pending_collection", "unchanged_turn"]),
|
||||
and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
|
||||
sql`NOT (coalesce(${copies.receipt}, '{}'::jsonb) ? 'retainedByRunId')`,
|
||||
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
|
||||
lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1))).limit(20);
|
||||
for (const { copy: row, runtimeMode } of pending) {
|
||||
@@ -352,5 +354,19 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
|
||||
}
|
||||
|
||||
async function release(companyId: string, runId: string) { liveTargets.delete(targetKey(companyId, runId)); const row = await get(companyId, runId); if (row && isAgentDirectoryCopy(row)) await directories.release(row); }
|
||||
return { prepare, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release };
|
||||
async function reportRetirementUnconfirmed(companyId: string, runId: string) {
|
||||
const row = await get(companyId, runId);
|
||||
if (!row || !isAgentDirectoryCopy(row) || row.receipt?.retainedByRunId || row.processStoppedAt) return row;
|
||||
return patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED",
|
||||
errorMessage: "The retained provider did not confirm retirement. Its registered agent directory is preserved; no save is claimed.", nextAttemptAt: null });
|
||||
}
|
||||
async function adopt(input: Parameters<typeof directories.adopt>[0]) {
|
||||
const row = await directories.adopt(input);
|
||||
if (row) {
|
||||
liveTargets.delete(targetKey(input.companyId, input.previousRunId));
|
||||
if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
return { prepare, adopt, reportRetirementUnconfirmed, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release };
|
||||
}
|
||||
@@ -220,6 +220,7 @@ import {
|
||||
buildNativeRuntimeContext,
|
||||
cancelNativeSession,
|
||||
claimNativeRestartRecoveries,
|
||||
claimWarmNativeInstructionCopy,
|
||||
closeWarmNativeSessionsForEnvironment,
|
||||
closeIdleWarmNativeSessionsForRestart,
|
||||
currentNativeControllerIdentity,
|
||||
@@ -20247,6 +20248,7 @@ export function heartbeatService(
|
||||
Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
|
||||
let nativeSessionResumeScheduled = false;
|
||||
let nativeOwnershipHeld = false;
|
||||
let releaseWarmInstructionPreparation: (() => Promise<void>) | null = null;
|
||||
let nativeDispatchStarted = false;
|
||||
let nativeWorkspaceFinalizeScheduled = false;
|
||||
let nativeWorkspaceSync: Awaited<
|
||||
@@ -22419,6 +22421,10 @@ export function heartbeatService(
|
||||
const executionTarget = realizationResult.executionTarget;
|
||||
let instructionCopy: Awaited<ReturnType<typeof instructionCopies.prepare>> = null;
|
||||
let instructionSave: Record<string, unknown> | null = null;
|
||||
const instructionPreparationKey = createHash("sha256").update(JSON.stringify({
|
||||
adapterType: agent.adapterType, adapterConfig: agent.adapterConfig, runtimeConfig: agent.runtimeConfig, sessionConfigMetadata,
|
||||
workspaceId: persistedExecutionWorkspace?.id ?? run.id, cwd: executionWorkspace.cwd,
|
||||
})).digest("hex");
|
||||
const collectStoppedInstructions = async () => {
|
||||
if (!instructionCopy) return;
|
||||
let saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget });
|
||||
@@ -22440,6 +22446,12 @@ export function heartbeatService(
|
||||
: saved.errorMessage ?? "Instruction edits were not saved. Review the preserved candidate in the agent instruction editor."),
|
||||
payload: instructionSave });
|
||||
};
|
||||
const nativeInstructionWorkingCopy = () => instructionCopy ? {
|
||||
...(isAgentDirectoryCopy(instructionCopy) ? { runId: run.id, preparationKey: instructionPreparationKey } : {}),
|
||||
hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
|
||||
collectStopped: collectStoppedInstructions,
|
||||
retirementFailed: async () => { await instructionCopies.reportRetirementUnconfirmed(agent.companyId, run.id); },
|
||||
} : undefined;
|
||||
if (managedAiRuntime && aiBinding) {
|
||||
try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); }
|
||||
catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); }
|
||||
@@ -23256,14 +23268,26 @@ export function heartbeatService(
|
||||
try {
|
||||
// Missing contract fields on a restored session mean the deployed
|
||||
// legacy format. New sessions opt into whole-directory persistence.
|
||||
const priorFileRun = taskSessionForRun?.lastRunId
|
||||
const priorFileRun = taskSession?.lastRunId
|
||||
? await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, taskSessionForRun.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0])
|
||||
eq(heartbeatRuns.id, taskSession.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0])
|
||||
: null;
|
||||
const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
|
||||
const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput);
|
||||
const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
|
||||
instructionCopy = await instructionCopies.prepare({
|
||||
if (nativeRuntimeResolution.kind === "native" && priorWorkingCopy.kind === "agent_files" && taskSession) {
|
||||
releaseWarmInstructionPreparation = await claimWarmNativeInstructionCopy({
|
||||
priorExecution: parseNativeExecutionInput(priorFileInput), companyId: agent.companyId, agentId: agent.id,
|
||||
executionWorkspaceId: persistedExecutionWorkspace?.id ?? run.id, cwd: executionWorkspace.cwd,
|
||||
environmentId: executionTarget?.environmentId ?? null, runId: run.id, preparationKey: taskSessionForRun ? instructionPreparationKey : `reset:${run.id}`,
|
||||
adopt: async previousRunId => {
|
||||
instructionCopy = await instructionCopies.adopt({ companyId: agent.companyId, agentId: agent.id,
|
||||
runId: run.id, previousRunId, target: executionTarget, cwd: executionWorkspace.cwd });
|
||||
return nativeInstructionWorkingCopy() ?? null;
|
||||
},
|
||||
});
|
||||
}
|
||||
instructionCopy ??= await instructionCopies.prepare({
|
||||
companyId: agent.companyId, agentId: agent.id, runId: run.id,
|
||||
target: executionTarget, cwd: executionWorkspace.cwd,
|
||||
legacy: Object.keys(priorFileInput).length > 0 && priorWorkingCopy.kind !== "agent_files",
|
||||
@@ -24449,10 +24473,7 @@ export function heartbeatService(
|
||||
},
|
||||
onLog,
|
||||
onEvent: onAdapterEvent,
|
||||
instructionWorkingCopy: instructionCopy ? {
|
||||
hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
|
||||
collectStopped: collectStoppedInstructions,
|
||||
} : undefined,
|
||||
instructionWorkingCopy: nativeInstructionWorkingCopy(),
|
||||
preparationSpans: nativeRunnerPreparationSpans,
|
||||
// Bootstrap with executable/home discovery while keeping
|
||||
// configured provider values and the server-selected
|
||||
@@ -26331,6 +26352,7 @@ export function heartbeatService(
|
||||
}
|
||||
// A retained or unverified process stays above this release boundary.
|
||||
// If no stopped-copy capture occurred, preserve an explicit loss report.
|
||||
await releaseWarmInstructionPreparation?.();
|
||||
const uncapturedInstructions = await instructionCopies.reportUnavailable(run.companyId, run.id);
|
||||
if (uncapturedInstructions?.state === "unavailable") {
|
||||
await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn",
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
import { createHash } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
|
||||
import { probeAgentDirectory } from "../agent-directory-probe.js";
|
||||
import { AGENT_FILES_CONTRACT } from "../agent-file-store.js";
|
||||
import { captureDirectorySnapshot, directorySnapshotSha256, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { agentInstructionWorkingCopies } from "@paperclipai/db";
|
||||
import * as noLaunchProofModule from "./native-maintenance-no-launch.js";
|
||||
import {
|
||||
NativeSessionCleanupQuarantinedError,
|
||||
@@ -276,6 +281,7 @@ import {
|
||||
buildNativeHarnessBackupManifest,
|
||||
cancelNativeSession,
|
||||
closeWarmNativeSessionsForEnvironment,
|
||||
claimWarmNativeInstructionCopy,
|
||||
closeIdleWarmNativeSessionsForRestart,
|
||||
createGovernedWaitEventObservation,
|
||||
createRemoteRunnerProcessLauncher,
|
||||
@@ -5971,6 +5977,140 @@ describe("native session same-turn steering", () => {
|
||||
});
|
||||
|
||||
describe("native warm session supervision", () => {
|
||||
it.each(["idle", "edit", "add", "remove"])("retains the actual agent_files copy across warm turns, then retires for %s", async ending => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "warm-agent-files-")));
|
||||
const localRoot = join(root, "run", "live");
|
||||
await mkdir(localRoot, { recursive: true });
|
||||
await writeFile(join(localRoot, "AGENTS.md"), "Keep the registered directory.\n");
|
||||
const snapshot = await captureDirectorySnapshot(localRoot);
|
||||
let row = { companyId: execution.binding.companyId, agentId: execution.binding.agentId,
|
||||
runId: "warm-real-agent-files", localRoot, executionRoot: localRoot, location: "local",
|
||||
entryFile: "AGENTS.md", state: "prepared", attempts: 0, processStoppedAt: null,
|
||||
baseRevisionId: null, candidateBase64: null, candidateHash: null, errorCode: null, errorMessage: null, nextAttemptAt: null,
|
||||
responsibleUserId: "test-user", createdAt: new Date(), updatedAt: new Date(),
|
||||
baseHash: directorySnapshotSha256(snapshot), receipt: { schema: AGENT_FILES_CONTRACT, baseline: serializeDirectorySnapshot(snapshot), materializationIdentity: probeAgentDirectory(localRoot).identity },
|
||||
} as typeof agentInstructionWorkingCopies.$inferSelect;
|
||||
const copies = agentDirectoryWorkingCopyService({} as Db, async () => row,
|
||||
async (_prior, values) => (row = { ...row, ...values } as typeof row));
|
||||
const order: string[] = [];
|
||||
const close = vi.fn(async () => { order.push("closed"); });
|
||||
const collectStopped = async () => {
|
||||
order.push("collected");
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
// The real DB service suite verifies changed-file persistence. Here the
|
||||
// fake backend proves stop precedes granting its collection callback.
|
||||
if (ending === "idle") await copies.collectStopped(row);
|
||||
};
|
||||
const identity = "warm-real-agent-files";
|
||||
const warmExecution = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` },
|
||||
session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } },
|
||||
} as NativeExecutionInputV1;
|
||||
const session = { close };
|
||||
state.execute.mockReset().mockImplementation(async (options) => {
|
||||
await options.onSession?.(session);
|
||||
return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" },
|
||||
turnId: identity, normalizedSessionId: identity, providerSessionId: identity,
|
||||
driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
|
||||
});
|
||||
try {
|
||||
await executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution,
|
||||
runnerInstanceId: identity, runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` },
|
||||
instructionWorkingCopy: { runId: identity, preparationKey: "same-preparation", hasChanges: () => copies.hasChanges(row), collectStopped } });
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(order).toEqual([]);
|
||||
expect(await readFile(join(localRoot, "AGENTS.md"), "utf8")).toContain("registered");
|
||||
const second = { ...warmExecution, binding: { ...warmExecution.binding, runId: `${identity}-second` } };
|
||||
const currentCopy = { runId: second.binding.runId, preparationKey: "same-preparation", hasChanges: () => copies.hasChanges(row), collectStopped };
|
||||
const release = await claimWarmNativeInstructionCopy({ priorExecution: warmExecution,
|
||||
companyId: warmExecution.binding.companyId, agentId: warmExecution.binding.agentId,
|
||||
executionWorkspaceId: warmExecution.binding.executionWorkspaceId, cwd: warmExecution.workspace.cwd,
|
||||
environmentId: identity, runId: second.binding.runId, preparationKey: "same-preparation",
|
||||
adopt: async () => currentCopy });
|
||||
expect(release).toBeTypeOf("function");
|
||||
await executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: identity,
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` },
|
||||
instructionWorkingCopy: currentCopy });
|
||||
expect(state.execute.mock.calls.at(-1)?.[0].existingSession).toBe(session);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(row.executionRoot).toBe(localRoot);
|
||||
await release?.(); // Consumed preparation cannot retire the retained owner.
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
if (ending === "idle") {
|
||||
await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "idle retirement" });
|
||||
await expect(access(localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} else {
|
||||
if (ending === "edit") await writeFile(join(localRoot, "AGENTS.md"), "Changed instructions");
|
||||
if (ending === "add") await writeFile(join(localRoot, "memory.txt"), "New memory");
|
||||
if (ending === "remove") await rm(join(localRoot, "AGENTS.md"));
|
||||
const adopt = vi.fn(async () => currentCopy);
|
||||
expect(await claimWarmNativeInstructionCopy({ priorExecution: second,
|
||||
companyId: second.binding.companyId, agentId: second.binding.agentId,
|
||||
executionWorkspaceId: second.binding.executionWorkspaceId, cwd: second.workspace.cwd,
|
||||
environmentId: identity, runId: `${identity}-third`, preparationKey: "same-preparation", adopt })).toBeNull();
|
||||
expect(adopt).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(order).toEqual(["closed", "collected"]);
|
||||
} finally {
|
||||
await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" });
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["final-config", "late-edit", "close-failure", "abandon", "concurrent", "foreign-company", "foreign-workspace"])("fences owned instruction preparation: %s", async scenario => {
|
||||
const identity = `warm-copy-${scenario}`;
|
||||
const order: string[] = [];
|
||||
let allowClose = scenario !== "close-failure";
|
||||
const session = { close: vi.fn(async () => { order.push("close"); if (!allowClose) throw new Error("retirement unconfirmed"); }) };
|
||||
const retirementFailed = vi.fn(async () => {});
|
||||
let lateEdit = false;
|
||||
const copy = { runId: identity, preparationKey: "key", hasChanges: async () => lateEdit,
|
||||
collectStopped: vi.fn(async () => { order.push("collect"); }), retirementFailed };
|
||||
const first = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` },
|
||||
session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } },
|
||||
} as NativeExecutionInputV1;
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId: identity, remoteCwd: `/tmp/${identity}` };
|
||||
state.execute.mockReset().mockImplementation(async options => {
|
||||
await options.onSession?.(session);
|
||||
return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" },
|
||||
turnId: identity, normalizedSessionId: identity, providerSessionId: identity,
|
||||
driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
|
||||
});
|
||||
const second = { ...first, binding: { ...first.binding, runId: `${identity}-second` } };
|
||||
const current = { ...copy, runId: second.binding.runId };
|
||||
const adopt = vi.fn(async () => current);
|
||||
const claim = { priorExecution: first, companyId: first.binding.companyId, agentId: first.binding.agentId,
|
||||
executionWorkspaceId: first.binding.executionWorkspaceId, cwd: first.workspace.cwd, environmentId: identity,
|
||||
runId: second.binding.runId, preparationKey: "key", adopt };
|
||||
try {
|
||||
await executePaperclipNativeSession({ db: leaseDb(first), execution: first, runnerInstanceId: identity, runnerExecutionTarget: target, instructionWorkingCopy: copy });
|
||||
if (scenario.startsWith("foreign")) {
|
||||
expect(await claimWarmNativeInstructionCopy({ ...claim,
|
||||
...(scenario === "foreign-company" ? { companyId: "other" } : { executionWorkspaceId: "other" }) })).toBeNull();
|
||||
expect(adopt).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled();
|
||||
} else {
|
||||
const release = await claimWarmNativeInstructionCopy(claim);
|
||||
if (scenario === "concurrent") await expect(claimWarmNativeInstructionCopy({ ...claim, runId: "competitor" })).rejects.toThrow("busy");
|
||||
if (scenario === "final-config" || scenario === "late-edit") {
|
||||
lateEdit = scenario === "late-edit";
|
||||
const changed = scenario === "late-edit" ? second : { ...second, session: { ...second.session, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 99_000 } } };
|
||||
await expect(executePaperclipNativeSession({ db: leaseDb(changed), execution: changed, runnerInstanceId: identity,
|
||||
runnerExecutionTarget: target, instructionWorkingCopy: current })).rejects.toThrow(scenario === "late-edit"
|
||||
? "native_instruction_preparation_copy_changed" : "native_instruction_preparation_configuration_changed");
|
||||
expect(state.execute).toHaveBeenCalledOnce();
|
||||
}
|
||||
if (scenario === "close-failure") {
|
||||
await expect(release!()).rejects.toThrow("retirement unconfirmed");
|
||||
expect(retirementFailed).toHaveBeenCalledOnce();
|
||||
expect(copy.collectStopped).not.toHaveBeenCalled();
|
||||
expect(order).toEqual(["close"]);
|
||||
} else {
|
||||
await release?.();
|
||||
expect(order).toEqual(["close", "collect"]);
|
||||
}
|
||||
}
|
||||
} finally { allowClose = true; await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ changed: false, closeFails: false },
|
||||
{ changed: true, closeFails: false },
|
||||
|
||||
@@ -391,7 +391,18 @@ function clearNativeRuntimeRequestResolutions(runId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeInstructionWorkingCopy = {
|
||||
/** Present only for a complete registered agent_files materialization. */
|
||||
runId?: string;
|
||||
preparationKey?: string;
|
||||
hasChanges: () => Promise<boolean>;
|
||||
collectStopped: () => Promise<void>;
|
||||
retirementFailed?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type WarmNativeSession = {
|
||||
instructionWorkingCopy?: NativeInstructionWorkingCopy;
|
||||
instructionPreparationRunId?: string;
|
||||
managedAiCredentialIdentity?: string;
|
||||
credentialRunId?: string;
|
||||
githubAccess?: NativeGitHubAccess;
|
||||
@@ -412,7 +423,15 @@ type WarmNativeSession = {
|
||||
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string) {
|
||||
// Revoke before awaiting process retirement/checkpoint IO.
|
||||
const stopping = entry.githubAccess?.stop();
|
||||
try { await entry.session.close({ reason }); }
|
||||
try {
|
||||
try { await entry.session.close({ reason }); }
|
||||
catch (error) {
|
||||
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
|
||||
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
|
||||
throw error;
|
||||
}
|
||||
await entry.instructionWorkingCopy?.collectStopped();
|
||||
}
|
||||
finally { await stopping; }
|
||||
}
|
||||
|
||||
@@ -421,6 +440,59 @@ const warmNativeSessions = new Map<string, WarmNativeSession>();
|
||||
// not inspect or quarantine that owner's state until the save has finished.
|
||||
const closingWarmNativeSessions = new Map<string, Promise<void>>();
|
||||
|
||||
/** Reserve the existing physical copy before heartbeat composes AGENT_HOME and
|
||||
* task guidance. The final execution/configuration fence still owns admission. */
|
||||
export async function claimWarmNativeInstructionCopy(input: {
|
||||
priorExecution: NativeExecutionInput;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
executionWorkspaceId: string;
|
||||
cwd: string;
|
||||
environmentId: string | null;
|
||||
runId: string;
|
||||
preparationKey: string;
|
||||
adopt: (previousRunId: string) => Promise<NativeInstructionWorkingCopy | null>;
|
||||
}): Promise<(() => Promise<void>) | null> {
|
||||
const prior = input.priorExecution;
|
||||
if (prior.binding.companyId !== input.companyId || prior.binding.agentId !== input.agentId
|
||||
|| prior.binding.executionWorkspaceId !== input.executionWorkspaceId || prior.workspace.cwd !== input.cwd) return null;
|
||||
const scope = nativeSessionScopeKey(prior);
|
||||
const entry = warmNativeSessions.get(scope);
|
||||
if (!entry || !entry.instructionWorkingCopy?.runId || entry.environmentId !== input.environmentId) return null;
|
||||
if (entry.busy) throw new Error("native_session_supervisor_busy");
|
||||
entry.busy = true;
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
entry.idleTimer = null;
|
||||
const retire = async (reason: string) => {
|
||||
if (warmNativeSessions.get(scope) !== entry) return;
|
||||
entry.closeOnReleaseReason = reason;
|
||||
try {
|
||||
await closeWarmNativeSession(entry, reason);
|
||||
if (warmNativeSessions.get(scope) === entry) warmNativeSessions.delete(scope);
|
||||
} finally { entry.busy = false; }
|
||||
};
|
||||
try {
|
||||
if (entry.closeOnReleaseReason !== undefined || entry.instructionWorkingCopy.preparationKey !== input.preparationKey || await entry.instructionWorkingCopy.hasChanges()) {
|
||||
await retire("warm instruction materialization changed before preparation");
|
||||
return null;
|
||||
}
|
||||
const adopted = await input.adopt(entry.instructionWorkingCopy.runId);
|
||||
if (!adopted) {
|
||||
await retire("warm instruction materialization cannot be adopted");
|
||||
return null;
|
||||
}
|
||||
entry.instructionWorkingCopy = adopted;
|
||||
entry.instructionPreparationRunId = input.runId;
|
||||
return async () => {
|
||||
if (entry.instructionPreparationRunId === input.runId) await retire("warm instruction preparation abandoned");
|
||||
};
|
||||
} catch (error) {
|
||||
if (entry.closeOnReleaseReason === undefined) await retire("warm instruction preparation failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Close idle native sessions before an operator destroys their remote
|
||||
* environment. A warm runner owns a long-lived sandbox command stream, so the
|
||||
@@ -467,19 +539,23 @@ async function closeIdleWarmNativeSessions(input: {
|
||||
continue;
|
||||
}
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
// Remove ownership before awaiting close so a racing continuation cannot
|
||||
// adopt a session whose transport is already shutting down.
|
||||
warmNativeSessions.delete(sessionId);
|
||||
// Keep a fenced owner until both retirement and stopped collection succeed.
|
||||
// Failure must not make a live directory eligible for a replacement owner.
|
||||
entry.busy = true;
|
||||
entry.closeOnReleaseReason = input.reason;
|
||||
const closing = Promise.resolve().then(() =>
|
||||
closeWarmNativeSession(entry, input.reason),
|
||||
);
|
||||
closingWarmNativeSessions.set(sessionId, closing);
|
||||
try {
|
||||
await closing;
|
||||
if (warmNativeSessions.get(sessionId) === entry) warmNativeSessions.delete(sessionId);
|
||||
closed += 1;
|
||||
} catch {
|
||||
if (!entry.instructionWorkingCopy?.runId) warmNativeSessions.delete(sessionId);
|
||||
failed += 1;
|
||||
} finally {
|
||||
entry.busy = false;
|
||||
if (closingWarmNativeSessions.get(sessionId) === closing) {
|
||||
closingWarmNativeSessions.delete(sessionId);
|
||||
}
|
||||
@@ -5223,7 +5299,7 @@ function hasIdleWarmNativeSessionOwner(input: {
|
||||
}): boolean {
|
||||
if (input.execution.session.lifecyclePolicy.mode !== "warm") return false;
|
||||
const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
|
||||
if (!entry || entry.busy) return false;
|
||||
if (!entry || (entry.busy && entry.instructionPreparationRunId !== input.execution.binding.runId)) return false;
|
||||
// A verified idle owner proves the checkpoint belongs to this session even
|
||||
// when its process must later rotate to a new run-scoped broker capability.
|
||||
// Local environments also have an id. Compare the same environment binding
|
||||
@@ -5935,23 +6011,27 @@ async function releaseWarmNativeSession(
|
||||
entry.lastActivityAt = new Date().toISOString();
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
if (failed || entry.closeOnReleaseReason !== undefined) {
|
||||
warmNativeSessions.delete(sessionId);
|
||||
const closing = closeWarmNativeSession(entry,
|
||||
entry.closeOnReleaseReason ?? "warm native session failed");
|
||||
// Restart checkpointing is required to restore this successful session.
|
||||
// Surface failure instead of reporting a clean release without authority.
|
||||
if (entry.closeOnReleaseReason !== undefined) await closing;
|
||||
else await closing.catch(() => undefined);
|
||||
const requireCleanClose = entry.closeOnReleaseReason !== undefined;
|
||||
entry.closeOnReleaseReason ??= "warm native session failed";
|
||||
entry.busy = true;
|
||||
try {
|
||||
await closeWarmNativeSession(entry, entry.closeOnReleaseReason);
|
||||
if (warmNativeSessions.get(sessionId) === entry) warmNativeSessions.delete(sessionId);
|
||||
} catch (error) {
|
||||
if (!entry.instructionWorkingCopy?.runId) warmNativeSessions.delete(sessionId);
|
||||
if (requireCleanClose) throw error;
|
||||
} finally { entry.busy = false; }
|
||||
return;
|
||||
}
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
const current = warmNativeSessions.get(sessionId);
|
||||
// clearTimeout cannot revoke an already-queued callback. The entry object
|
||||
// is the idle timer's ownership fence across a later warm acquisition.
|
||||
if (current !== entry || current.busy) return;
|
||||
warmNativeSessions.delete(sessionId);
|
||||
void closeWarmNativeSession(current, "warm native session idle timeout")
|
||||
.catch(() => undefined);
|
||||
current.busy = true;
|
||||
current.closeOnReleaseReason = "warm native session idle timeout";
|
||||
void closeWarmNativeSession(current, current.closeOnReleaseReason)
|
||||
.then(() => { if (warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); })
|
||||
.catch(() => { if (!current.instructionWorkingCopy?.runId && warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); })
|
||||
.finally(() => { current.busy = false; });
|
||||
}, idleTimeoutMs);
|
||||
entry.idleTimer.unref();
|
||||
}
|
||||
@@ -7205,10 +7285,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
onEvent?: (event: AdapterRuntimeEvent) => Promise<void>;
|
||||
/** Only this run's registered private instruction entry.
|
||||
* Probe at terminal; persist only after owned shutdown. */
|
||||
instructionWorkingCopy?: {
|
||||
hasChanges: () => Promise<boolean>;
|
||||
collectStopped: () => Promise<void>;
|
||||
};
|
||||
instructionWorkingCopy?: NativeInstructionWorkingCopy;
|
||||
/** Persist task-level continuity before a durable goal can outlive this run. */
|
||||
onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise<void>;
|
||||
sessionGoalControl?: NativeSessionGoalControl | null;
|
||||
@@ -8099,17 +8176,36 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
(input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS ===
|
||||
"enabled")
|
||||
) {
|
||||
if (entry.busy) throw new Error("native_session_supervisor_busy");
|
||||
const preparedCopy = entry.instructionPreparationRunId === input.execution.binding.runId;
|
||||
if (entry.busy && !preparedCopy) throw new Error("native_session_supervisor_busy");
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
warmNativeSessions.delete(warmSessionId);
|
||||
await closeWarmNativeSession(entry, "warm native session configuration changed");
|
||||
entry.busy = true;
|
||||
entry.closeOnReleaseReason = "warm native session configuration changed";
|
||||
try {
|
||||
await closeWarmNativeSession(entry, entry.closeOnReleaseReason);
|
||||
warmNativeSessions.delete(warmSessionId);
|
||||
} finally { entry.busy = false; }
|
||||
if (preparedCopy) throw new Error("native_instruction_preparation_configuration_changed");
|
||||
persistedWarmSession = loadWarmNativeCheckpoint(
|
||||
input.execution,
|
||||
warmConfigDigest,
|
||||
input.runnerExecutionTarget?.kind ?? "local",
|
||||
);
|
||||
} else {
|
||||
if (entry.busy) throw new Error("native_session_supervisor_busy");
|
||||
if (entry.busy && entry.instructionPreparationRunId !== input.execution.binding.runId) throw new Error("native_session_supervisor_busy");
|
||||
if (entry.instructionWorkingCopy?.runId && entry.instructionPreparationRunId !== input.execution.binding.runId) {
|
||||
throw new Error("native_instruction_materialization_not_claimed");
|
||||
}
|
||||
if (entry.instructionPreparationRunId !== undefined && await entry.instructionWorkingCopy!.hasChanges()) {
|
||||
entry.closeOnReleaseReason = "warm instruction copy changed during preparation";
|
||||
try {
|
||||
await closeWarmNativeSession(entry, entry.closeOnReleaseReason);
|
||||
warmNativeSessions.delete(warmSessionId);
|
||||
} finally { entry.busy = false; }
|
||||
throw new Error("native_instruction_preparation_copy_changed");
|
||||
}
|
||||
entry.instructionPreparationRunId = undefined;
|
||||
entry.instructionWorkingCopy = input.instructionWorkingCopy;
|
||||
entry.busy = true;
|
||||
entry.ownerToken = warmSessionOwnerToken;
|
||||
entry.environmentId =
|
||||
@@ -8443,6 +8539,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
existing.session = session;
|
||||
} else
|
||||
warmNativeSessions.set(warmSessionId, {
|
||||
instructionWorkingCopy: input.instructionWorkingCopy,
|
||||
managedAiCredentialIdentity: input.managedAiCredentialIdentity,
|
||||
githubAuthenticationMode:
|
||||
input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE,
|
||||
@@ -9128,7 +9225,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
lifecyclePolicy.idleTimeoutMs,
|
||||
false,
|
||||
);
|
||||
if (collectInstructions) await instructionCopy!.collectStopped();
|
||||
// releaseWarmNativeSession closes and collects through the same owner.
|
||||
}
|
||||
const adapterResult: AdapterExecutionResult = {
|
||||
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
|
||||
|
||||
Reference in new issue
Block a user