mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix: share one bounded lifetime across computer file operations
Propagate the admitted operation deadline through readiness, claims, and SSH commands. Keep only a bounded transport-settlement hold, and expire a short file waiter without cancelling shared SSH initialization. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2c09c8c16d
commit
9c583bf39d
5 files changed
+188
-28
No files matched your search
@@ -23,14 +23,16 @@ export interface ComputerRepository {
|
||||
agentId: string,
|
||||
): Promise<"active" | "terminal" | "missing">;
|
||||
}
|
||||
export type ComputerOperationOptions = { deadlineMs?: number };
|
||||
|
||||
export interface ComputerBackend {
|
||||
inspect(record: ComputerRecord): Promise<{
|
||||
state: string;
|
||||
snapshots: boolean;
|
||||
stop: null | { id: string; status: string };
|
||||
}>;
|
||||
ready(record: ComputerRecord): Promise<void>;
|
||||
claim(record: ComputerRecord): Promise<void>;
|
||||
ready(record: ComputerRecord, options?: ComputerOperationOptions): Promise<void>;
|
||||
claim(record: ComputerRecord, options?: ComputerOperationOptions): Promise<void>;
|
||||
advance(record: ComputerRecord, owner: Owner): Promise<void>;
|
||||
runnerPorts(record: ComputerRecord): Promise<RunnerPortAvailability>;
|
||||
runner(record: ComputerRecord, options?: { control?: boolean }): Promise<CommandManagedRuntimeRunner>;
|
||||
@@ -66,6 +68,6 @@ export interface ComputerBackend {
|
||||
path: string,
|
||||
): Promise<{ url: string; secretHeaders: Record<string, string> }>;
|
||||
preview(record: ComputerRecord, port: number): Promise<{ url: string }>;
|
||||
remote(record: ComputerRecord, input: Record<string, unknown>): Promise<any>;
|
||||
remote(record: ComputerRecord, input: Record<string, unknown>, options?: ComputerOperationOptions): Promise<any>;
|
||||
}
|
||||
export type { Ledger };
|
||||
@@ -512,6 +512,66 @@ describe("computer ownership", () => {
|
||||
).resolves.toHaveProperty("owner");
|
||||
expect(f.backend.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("shares one file budget across readiness, claim, and command without early retirement", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
const files = await f.service.files({ ...f.scope, agentId: "agent" });
|
||||
const expectedDeadline = Date.parse("2026-10-10T12:02:00Z");
|
||||
vi.mocked(f.backend.ready).mockImplementation(async (_record, options) => {
|
||||
expect(options?.deadlineMs).toBe(expectedDeadline);
|
||||
f.advance(90_000);
|
||||
});
|
||||
vi.mocked(f.backend.claim).mockImplementation(async (_record, options) => {
|
||||
expect(options?.deadlineMs).toBe(expectedDeadline);
|
||||
f.advance(20_000);
|
||||
});
|
||||
vi.mocked(f.backend.remote).mockImplementation(async (_record, _input, options) => {
|
||||
expect(options?.deadlineMs).toBe(expectedDeadline);
|
||||
f.advance(9999);
|
||||
await f.service.reconcile();
|
||||
expect(f.backend.stop).not.toHaveBeenCalled();
|
||||
f.advance(1);
|
||||
return {};
|
||||
});
|
||||
await files.stat("AGENTS.md");
|
||||
f.advance(5001);
|
||||
await f.service.reconcile();
|
||||
expect(f.backend.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not launch file work after slow readiness consumes its budget", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
const files = await f.service.files({ ...f.scope, agentId: "agent" });
|
||||
vi.mocked(f.backend.claim).mockClear();
|
||||
vi.mocked(f.backend.ready).mockImplementation(async () => { f.advance(120_000); });
|
||||
await expect(files.stat("AGENTS.md")).rejects.toThrow("timed out");
|
||||
expect(f.backend.claim).not.toHaveBeenCalled();
|
||||
expect(f.backend.remote).not.toHaveBeenCalled();
|
||||
f.advance(5001);
|
||||
await f.service.reconcile();
|
||||
expect(f.backend.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("bounds an abandoned file hold and fences a late readiness continuation", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
const files = await f.service.files({ ...f.scope, agentId: "agent" });
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const readyEntered = new Promise<void>(resolve => { entered = resolve; });
|
||||
vi.mocked(f.backend.claim).mockClear();
|
||||
vi.mocked(f.backend.ready).mockImplementation(() => new Promise<void>(resolve => { finish = resolve; entered(); }));
|
||||
const operation = files.stat("AGENTS.md");
|
||||
const rejected = expect(operation).rejects.toThrow("timed out");
|
||||
await readyEntered;
|
||||
f.advance(125_001);
|
||||
await f.service.reconcile();
|
||||
expect(f.backend.stop).toHaveBeenCalledOnce();
|
||||
finish();
|
||||
await rejected;
|
||||
expect(f.backend.claim).not.toHaveBeenCalled();
|
||||
expect(f.backend.remote).not.toHaveBeenCalled();
|
||||
expect((await f.repository.get(f.scope)).ledger.owners.every(owner => owner.phase === "retired")).toBe(true);
|
||||
});
|
||||
it("keeps a short bounded file batch hold between editor operations", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
|
||||
@@ -26,6 +26,8 @@ class ComputerSessionBusyError extends ComputerError {
|
||||
|
||||
// This is shutdown time only; admission and ordinary work stop at the warm deadline.
|
||||
const RETIREMENT_GRACE_MS = 30_000;
|
||||
const FILE_OPERATION_BUDGET_MS = 120_000;
|
||||
const FILE_OPERATION_SETTLEMENT_MS = 5000;
|
||||
type Scope = { companyId: string; environmentId: string };
|
||||
export function createComputerService(
|
||||
repository: ComputerRepository,
|
||||
@@ -697,26 +699,39 @@ finally:
|
||||
async function withFiles<T>(
|
||||
input: Scope,
|
||||
root: string,
|
||||
fn: (record: ComputerRecord) => Promise<T>,
|
||||
fn: (record: ComputerRecord, deadlineMs: number) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const data = await admitRecord(input, (record) => {
|
||||
const deadlineMs = now().getTime() + FILE_OPERATION_BUDGET_MS;
|
||||
const owner: Owner = {
|
||||
id: randomUUID(),
|
||||
generation: 1,
|
||||
kind: "file-operation",
|
||||
phase: "active",
|
||||
port: 0,
|
||||
deadline: new Date(now().getTime() + 120_000).toISOString(),
|
||||
// Transport cancellation has a bounded settlement window; it is not
|
||||
// additional time in which commands may start or continue.
|
||||
deadline: new Date(deadlineMs + FILE_OPERATION_SETTLEMENT_MS).toISOString(),
|
||||
absoluteDeadline: null,
|
||||
process: null,
|
||||
};
|
||||
record.ledger.owners.push(owner);
|
||||
return { record: structuredClone(record), owner };
|
||||
return { record: structuredClone(record), owner, deadlineMs };
|
||||
});
|
||||
const assertLive = async () => {
|
||||
if (now().getTime() >= data.deadlineMs)
|
||||
throw new ComputerError("provider_error", "Computer file operation timed out");
|
||||
const current = await repository.get(input);
|
||||
if (exactOwner(current, ref(data.record, data.owner)).phase !== "active")
|
||||
throw new ComputerError("conflict", "Computer file operation is no longer active");
|
||||
};
|
||||
try {
|
||||
await backend.ready(data.record);
|
||||
await backend.claim(data.record);
|
||||
return await fn(data.record);
|
||||
await assertLive();
|
||||
await backend.ready(data.record, { deadlineMs: data.deadlineMs });
|
||||
await assertLive();
|
||||
await backend.claim(data.record, { deadlineMs: data.deadlineMs });
|
||||
await assertLive();
|
||||
return await fn(data.record, data.deadlineMs);
|
||||
} finally {
|
||||
await repository.update(input, (record) => {
|
||||
const owner = record.ledger.owners.find(
|
||||
@@ -724,15 +739,18 @@ finally:
|
||||
);
|
||||
if (owner?.phase === "active") {
|
||||
owner.phase = "warm";
|
||||
owner.deadline = new Date(now().getTime() + 5000).toISOString();
|
||||
owner.deadline = new Date(Math.min(
|
||||
now().getTime() + FILE_OPERATION_SETTLEMENT_MS,
|
||||
data.deadlineMs + FILE_OPERATION_SETTLEMENT_MS,
|
||||
)).toISOString();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
function fileAccess(input: Scope, root: string) {
|
||||
const remote = (payload: Record<string, unknown>) =>
|
||||
withFiles(input, root, (record) =>
|
||||
backend.remote(record, { ...payload, root }),
|
||||
withFiles(input, root, (record, deadlineMs) =>
|
||||
backend.remote(record, { ...payload, root }, { deadlineMs }),
|
||||
);
|
||||
const readBytes = async (
|
||||
path: string,
|
||||
|
||||
Reference in new issue
Block a user