mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Recovery must preserve execution ownership and accepted results. > - A saved PID proves nothing outside its recorded process namespace. > - Uncertain unfinished work must stay held until its original owners stop. > - Operators need a source-bound containment action and a separate ordinary Retry. > - Accepted results must use the existing finalizer without another provider turn. > - This PR keeps those paths safe, recoverable, and quiet between real obligations. ## Linked Issues or Issue Description Refs: #19713 Refs: #19591 Recovery could use a local PID to release or replace a controller from another process namespace. Connected-chat Retry also rejected successful containment on a task in `todo`. The new containment action used a broader permission than the existing owner-stop action. A later review found that result-less guards also rejected the existing accepted-result confirmation action. ## What Changed - Record and verify the controller's kernel/PID namespace. Keep unknown and foreign unfinished ownership held. Preserve independent durable stop evidence and the existing remote lease fences. - Use the shared accepted-result ownership reconciler. Bind settlement to the observed lease token, generation, boot, PID, namespace and process birth. Retain finalizer, cleanup, current-controller, live-owner and remote exclusions. - Share the remote-owner reader across recovery consumers. Scope runner leases to the same company, task and run. Do not make local PID probes for remote accepted-result ownership. - Gate original-host containment on `runtime:confirm_owner_stop`. Bind it to the current task, source, assignee, original expiry, lease and Stop state. Audit separate controller/provider containment and reviewed action outcomes. - Keep containment separate from ordinary Retry. Use one canonical committed receipt and the normal admission, budget, workspace and lineage checks. Preserve the recorded external-action outcomes. - Keep the controller-only confirmation actionable for both result-less work and accepted results. Result-less work gets its next source-bound attention in the same transaction. Accepted results keep their original result and use normal shared settlement/finalization. - Keep identity inspection quiet. Preserve the original foreign-owner expiry and use the existing scoped finalizer in accepted-result recovery controls. - Update the recovery card, validators, execution projection, docs and access maps. Reuse existing canonical columns; add no migration or ownership backfill. ## Verification Current publication head: `71ea76b568de09d6313d8992e803131d9d5c54f3`. **The current fix has a causal red/green check.** On the test-only revision, both foreign-local stopped/fenced controller cases failed because recovery probed saved runner, process-group and provider PIDs in this host's namespace. This measured the wrong probes; it did not run duplicate providers. After the fix, the same two cases passed every no-probe, no-claim, no-controller-stamp, no-stop-receipt and unchanged-authority assertion. Three existing remote preparation, grace and fenced-controller progress cases also passed. Total: **5 passed, 0 failed;170 filtered rows excluded**. All three normal affected App TypeScript programs passed on this head (660,681 and428 roots). The fix separates a controller wait from execution stop proof. Independent live/grace evidence still schedules recovery. A stopped/fenced foreign controller can pass this namespace guard only for remote execution, which keeps its later checkpoint and never-launched checks. Foreign local execution stays held before runner or provider PID inspection. Earlier source-scoped results remain evidence, not fresh executions on the current head: - On `c86dd741`, the complete failed-owner file passed **25/25**, with no skips or filters. Empty scans wrote no controller stamps. Foreign ownership kept its original expiry. Normal returned-claim settlement and attention at the exact deadline passed. - On `c86dd741`, **15 selected accepted-result/restart controls passed**. The81 filtered rows are excluded. Both completion cases used the existing scoped finalizer and kept their accepted result without another provider turn. - Trusted CI at synthetic merge `50d072a5` (actual master `1b7fe334` plus `c86dd741`) passed the complete **17-case maintenance-server file**. This includes the original90-real-second quiet observation and two qualified hourly windows. The quiet case took118,918ms. The four jobs that failed on the earlier publication all passed on that synthetic merge. - On `6243b32c`, the complete ownership-report file passed **29/29**. Native NULL namespace stayed unprovable and generic compatibility remained. Three selected recovery controls passed;170 filtered rows are excluded. - On `f81f2c3c`,356 business cases passed: whole ownership-route221, recoveryUI70, owner-proof38 and controller-confirmation8; selected accepted-result authority18; and one real connected-chat containment, separate authorized Retry, useful Native result and downstream reply case. The2,024 filtered rows are excluded. A separate earlier accepted-result causal case kept the original result and completed after restart without replay. Normal Shared exports, App programs, UI typecheck, token gates and map checks passed on their recorded revisions. Unchanged DB, adapter, Eval and Runner inputs/outputs have explicit carry proofs. The clean normal merge with fetched master `8fe524f7` preserves all31 exclusive owned paths and14,827 unrelated master entries. The nine overlapping paths were reviewed, including the upstream unidentified-run stop action. Fresh CI must validate that actual merge composition. PG cases used existing external PG17, fresh owned databases, pools at most4, one worker and no SQL observer during cases. Registered clients and recorded process groups closed. Independent final SQL audits found zero campaign sessions before normal database cleanup. Original failing fixtures remain retained. Original failures are preserved. One new test first failed compilation due to a missing getter import; an import-only correction passed its full owning programs before the causal red/green check. A local quiet attempt stopped in the normal prerequisite hook before any business case and its collector rejected two normal build markers. The later17-case CI pass establishes quiet recovery; it does not turn that failed local collector into a pass. An earlier useful case passed before its private cleanup collector failed on an owned read-only directory; normal cleanup completed after all owners closed. Earlier runner-shutdown cancellation and a separate private whole-suite failure remain unexplained. Current-head checks are complete:57 success,1 neutral and3 skipped, including the successful aggregate CI gate. The legacy status passed. Greptile is5/5 and Ponytail gives an explicit5/5 Ship verdict for this head. All seven review threads are resolved. Full recursive typecheck, the full test suite and release build were not repeated locally. No result here proves deployed suspension, a live provider or termination on another host. ## Risks - Unknown physical ownership can require an owner/admin action. A false containment statement can be unsafe. - New work, reassignment, Stop, renewed authority, accepted results or pending cleanup can invalidate an action. The operator must refresh. - Controller confirmation does not prove provider/tool containment. Completed external actions must not be replayed. - Generic legacy and child-process compatibility remains separate from the strict Native controller policy. - Existing saved state and canonical schema are preserved. Permanent safety refusals remain held with their normal named recovery path. ## Model Used OpenAI `gpt-6.1-sol` agents implemented and tested the changes with reasoning, repository tools and code execution. A GPT-6-based Codex managing agent reviewed and integrated them. The host does not expose the manager's exact build or the context-window sizes. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
313 lines
18 KiB
TypeScript
313 lines
18 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { currentProcessStartedAtMs, processExists, resetCurrentProcessStartedAtForTests } from "./controller-process-identity.js";
|
|
import * as hotRestart from "./hot-restart.js";
|
|
import {
|
|
currentLocalOwnerStamp,
|
|
currentDeploymentIdentity,
|
|
currentProcessProbe,
|
|
proveOwnerGone,
|
|
type LocalOwnerStamp,
|
|
type ProcessProbe,
|
|
type RemoteOwnerStamp,
|
|
} from "./owner-proof.js";
|
|
|
|
const STARTED = "2026-11-07T10:00:00.000Z";
|
|
const at = (offsetMs: number) => new Date(Date.parse(STARTED) + offsetMs);
|
|
|
|
function stamp(overrides: Partial<LocalOwnerStamp> = {}): LocalOwnerStamp {
|
|
return {
|
|
kind: "local", scope: "test", generation: 1, host: "host-a", processSpace: "space-a",
|
|
bootId: "previous-boot", pid: 4242, processStartedAt: STARTED, processGroupId: null, ...overrides,
|
|
};
|
|
}
|
|
|
|
function probe(overrides: Partial<ProcessProbe> & {
|
|
alive?: number[]; groups?: number[]; starts?: Record<number, Date | null>;
|
|
} = {}): ProcessProbe {
|
|
const { alive = [], groups = [], starts = {}, ...rest } = overrides;
|
|
return {
|
|
bootId: "current-boot", host: "host-a", processSpace: "space-a",
|
|
pidExists: pid => alive.includes(pid),
|
|
processGroupExists: group => groups.includes(group),
|
|
readStartedAt: async pid => starts[pid] ?? null,
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
describe("proveOwnerGone: local owners", () => {
|
|
it("treats this process's own boot as alive", async () => {
|
|
expect(await proveOwnerGone(stamp({ bootId: "current-boot" }), probe())).toEqual({ verdict: "alive", reason: "current_process" });
|
|
});
|
|
|
|
it("proves a dead PID in the same PID space gone", async () => {
|
|
expect(await proveOwnerGone(stamp(), probe())).toEqual({ verdict: "gone", reason: "pid_dead" });
|
|
});
|
|
|
|
it.each([null, "foreign-space", "unknown"])("does not probe a recorded-space-required owner in %s space", async processSpace => {
|
|
const pidExists = vi.fn(() => false), readStartedAt = vi.fn(async () => null);
|
|
const result = await proveOwnerGone(stamp({ processSpace, processSpaceUnrecorded: processSpace === null,
|
|
requireRecordedProcessSpace: true }), probe({ pidExists, readStartedAt }));
|
|
expect(result).toEqual({ verdict: "unprovable", reason: processSpace === null ? "process_space_unrecorded"
|
|
: processSpace === "unknown" ? "unknown_process_space" : "different_process_space" });
|
|
expect(pidExists).not.toHaveBeenCalled(); expect(readStartedAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["stopped", "fenced"] as const)("retains independent controller-instance %s evidence for an unrecorded Native space", async state => {
|
|
const pidExists = vi.fn(() => false), readStartedAt = vi.fn(async () => null);
|
|
const controllerInstance = vi.fn(async () => state);
|
|
expect(await proveOwnerGone(stamp({ processSpace: null, processSpaceUnrecorded: true,
|
|
requireRecordedProcessSpace: true }), probe({ pidExists, readStartedAt, controllerInstance })))
|
|
.toEqual({ verdict: "gone", reason: state === "stopped" ? "controller_stopped" : "controller_fenced" });
|
|
expect(controllerInstance).toHaveBeenCalledOnce();
|
|
expect(pidExists).not.toHaveBeenCalled(); expect(readStartedAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts audited operator stop evidence without probing a recorded-space-required owner", async () => {
|
|
const pidExists = vi.fn(() => false), readStartedAt = vi.fn(async () => null);
|
|
expect(await proveOwnerGone(stamp({ processSpace: "operator_attested", requireRecordedProcessSpace: true }),
|
|
probe({ pidExists, readStartedAt }))).toEqual({ verdict: "gone", reason: "operator_attested" });
|
|
expect(pidExists).not.toHaveBeenCalled(); expect(readStartedAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("judges a row saved before its PID space locally for one release, on single-host servers only", async () => {
|
|
const legacy = stamp({ host: null, processSpace: null, processSpaceUnrecorded: true });
|
|
expect(await proveOwnerGone(legacy, probe())).toEqual({ verdict: "gone", reason: "pid_dead", legacyHostFallback: true });
|
|
expect(await proveOwnerGone(legacy, probe({ alive: [4242], starts: { 4242: at(0) } })))
|
|
.toEqual({ verdict: "alive", reason: "pid_alive" });
|
|
// Without the marker a missing space is missing identity, never proof.
|
|
expect(await proveOwnerGone(stamp({ processSpace: null }), probe())).toEqual({ verdict: "unprovable", reason: "missing_identity" });
|
|
vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true");
|
|
try {
|
|
expect(await proveOwnerGone(legacy, probe())).toEqual({ verdict: "unprovable", reason: "process_space_unrecorded" });
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("accepts a board attestation that this exact recorded owner stopped", async () => {
|
|
expect(await proveOwnerGone(stamp({ processSpace: "operator_attested" }), probe({ alive: [4242], starts: { 4242: at(0) } })))
|
|
.toEqual({ verdict: "gone", reason: "operator_attested" });
|
|
});
|
|
|
|
it("never treats a different host as proof, even when the PID is dead here", async () => {
|
|
expect(await proveOwnerGone(stamp({ host: "host-b" }), probe())).toEqual({ verdict: "unprovable", reason: "different_host" });
|
|
});
|
|
|
|
it("never treats a different PID namespace or kernel boot as proof", async () => {
|
|
expect(await proveOwnerGone(stamp({ processSpace: "space-b" }), probe()))
|
|
.toEqual({ verdict: "unprovable", reason: "different_process_space" });
|
|
expect(await proveOwnerGone(stamp({ processSpace: "unknown" }), probe()))
|
|
.toEqual({ verdict: "unprovable", reason: "unknown_process_space" });
|
|
expect(await proveOwnerGone(stamp(), probe({ processSpace: "unknown" })))
|
|
.toEqual({ verdict: "unprovable", reason: "unknown_process_space" });
|
|
});
|
|
|
|
it("treats a stamp without a PID or process space as unprovable", async () => {
|
|
expect(await proveOwnerGone(stamp({ pid: null }), probe())).toEqual({ verdict: "unprovable", reason: "missing_identity" });
|
|
expect(await proveOwnerGone(stamp({ processSpace: null }), probe())).toEqual({ verdict: "unprovable", reason: "missing_identity" });
|
|
});
|
|
|
|
it("keeps a live PID whose start time is within 1 s of the stamp alive (ps lstart precision)", async () => {
|
|
for (const offset of [0, 1, 999, -999]) {
|
|
expect(await proveOwnerGone(stamp(), probe({ alive: [4242], starts: { 4242: at(offset) } })))
|
|
.toEqual({ verdict: "alive", reason: "pid_alive" });
|
|
}
|
|
});
|
|
|
|
it("proves a reused PID gone only at a start-time gap of at least 1 s", async () => {
|
|
expect(await proveOwnerGone(stamp(), probe({ alive: [4242], starts: { 4242: at(1_000) } })))
|
|
.toEqual({ verdict: "gone", reason: "pid_reused" });
|
|
expect(await proveOwnerGone(stamp(), probe({ alive: [4242], starts: { 4242: at(60_000) } })))
|
|
.toEqual({ verdict: "gone", reason: "pid_reused" });
|
|
});
|
|
|
|
it("treats a live PID that started before the stamp as inconsistent, not reused", async () => {
|
|
expect(await proveOwnerGone(stamp(), probe({ alive: [4242], starts: { 4242: at(-60_000) } })))
|
|
.toEqual({ verdict: "unprovable", reason: "pid_start_inconsistent" });
|
|
});
|
|
|
|
it("treats a live PID with an unreadable or unrecorded start time as unprovable", async () => {
|
|
expect(await proveOwnerGone(stamp(), probe({ alive: [4242] })))
|
|
.toEqual({ verdict: "unprovable", reason: "pid_start_unreadable" });
|
|
expect(await proveOwnerGone(stamp({ processStartedAt: null }), probe({ alive: [4242], starts: { 4242: at(5_000) } })))
|
|
.toEqual({ verdict: "unprovable", reason: "pid_start_unreadable" });
|
|
});
|
|
|
|
it("treats EPERM from signal 0 as a live process", async () => {
|
|
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw Object.assign(new Error("operation not permitted"), { code: "EPERM" });
|
|
});
|
|
expect(processExists(4242)).toBe(true);
|
|
const real = currentProcessProbe();
|
|
const result = await proveOwnerGone(
|
|
stamp({ host: real.host, processSpace: real.processSpace, processStartedAt: STARTED }),
|
|
{ ...real, readStartedAt: async () => at(0) },
|
|
);
|
|
expect(result).toEqual({ verdict: "alive", reason: "pid_alive" });
|
|
});
|
|
|
|
it("treats ESRCH from signal 0 as a dead process", async () => {
|
|
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
});
|
|
expect(processExists(4242)).toBe(false);
|
|
});
|
|
|
|
it("keeps the owner alive while its own process group still has members", async () => {
|
|
// The leader exited; its children keep the group alive.
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 4242 }), probe({ groups: [4242] })))
|
|
.toEqual({ verdict: "alive", reason: "process_group_alive" });
|
|
// The recorded leader still leads the group.
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000, processGroupStartedAt: STARTED }),
|
|
probe({ groups: [5000], alive: [5000], starts: { 5000: at(500) } })))
|
|
.toEqual({ verdict: "alive", reason: "process_group_alive" });
|
|
});
|
|
|
|
it("never proves gone a surviving child group that started after its parent", async () => {
|
|
// The owner started at 10:00, spawned a detached group at 10:01 and exited.
|
|
// Without the group leader's recorded start time, the group may be its own.
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000 }),
|
|
probe({ groups: [5000], alive: [5000], starts: { 5000: at(60_000) } })))
|
|
.toEqual({ verdict: "unprovable", reason: "process_group_ambiguous" });
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000, processGroupStartedAt: at(60_000).toISOString() }),
|
|
probe({ groups: [5000], alive: [5000], starts: { 5000: at(60_000) } })))
|
|
.toEqual({ verdict: "alive", reason: "process_group_alive" });
|
|
});
|
|
|
|
it("proves the owner gone when its process group is empty or led by a different process", async () => {
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 4242 }), probe())).toEqual({ verdict: "gone", reason: "pid_dead" });
|
|
// A reused owner PID now leads group 4242: the kernel does not reuse a PID
|
|
// while its old group has members, so the old group is empty.
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 4242 }),
|
|
probe({ groups: [4242], alive: [4242], starts: { 4242: at(30_000) } })))
|
|
.toEqual({ verdict: "gone", reason: "pid_reused" });
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000, processGroupStartedAt: at(-10_000).toISOString() }),
|
|
probe({ groups: [5000], alive: [5000], starts: { 5000: at(30_000) } })))
|
|
.toEqual({ verdict: "gone", reason: "pid_dead" });
|
|
});
|
|
|
|
it("never treats an older group leader as a replacement group", async () => {
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000, processGroupStartedAt: STARTED }),
|
|
probe({ groups: [5000], alive: [5000], starts: { 5000: at(-30_000) } })))
|
|
.toEqual({ verdict: "unprovable", reason: "process_group_ambiguous" });
|
|
});
|
|
|
|
it("treats a non-empty group with an unreadable leader as unprovable", async () => {
|
|
expect(await proveOwnerGone(stamp({ processGroupId: 5000, processGroupStartedAt: STARTED }),
|
|
probe({ groups: [5000], alive: [5000] })))
|
|
.toEqual({ verdict: "unprovable", reason: "process_group_ambiguous" });
|
|
});
|
|
|
|
it("stamps the start time resolved by any earlier identity read", async () => {
|
|
resetCurrentProcessStartedAtForTests();
|
|
vi.spyOn(hotRestart, "readProcessStartedAt").mockResolvedValue("2026-11-07T09:00:00.000Z");
|
|
await currentProcessStartedAtMs();
|
|
expect(currentLocalOwnerStamp("test").processStartedAt).toBe("2026-11-07T09:00:00.000Z");
|
|
});
|
|
|
|
it("stamps this process without I/O and proves it alive", async () => {
|
|
const read = vi.spyOn(hotRestart, "readProcessStartedAt");
|
|
const own = currentLocalOwnerStamp("test", 3);
|
|
expect(own).toMatchObject({ kind: "local", pid: process.pid, generation: 3 });
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(await proveOwnerGone(own)).toEqual({ verdict: "alive", reason: "current_process" });
|
|
});
|
|
});
|
|
|
|
describe("proveOwnerGone: replaced platform deployment", () => {
|
|
const worker = { provider: "railway" as const, projectId: "project", environmentId: "env", serviceId: "service", deploymentId: "old" };
|
|
const current = (overrides: Partial<typeof worker> = {}, exclusiveVolume = true) =>
|
|
probe({ host: "new-container", processSpace: "space-new", deployment: { worker: { ...worker, deploymentId: "new", ...overrides }, exclusiveVolume } });
|
|
|
|
it("proves an owner on an earlier deployment of the same volume-backed service gone, on any host", async () => {
|
|
expect(await proveOwnerGone(stamp({ worker, host: "old-container", processSpace: null }), current()))
|
|
.toEqual({ verdict: "gone", reason: "deployment_replaced" });
|
|
});
|
|
|
|
it("never treats this process's own boot as replaced", async () => {
|
|
expect(await proveOwnerGone(stamp({ worker, bootId: "current-boot" }), current()))
|
|
.toEqual({ verdict: "alive", reason: "current_process" });
|
|
});
|
|
|
|
it("is no proof without an exclusive volume, for the same deployment, or for another service or environment", async () => {
|
|
const old = stamp({ worker, host: "old-container" });
|
|
expect((await proveOwnerGone(old, current({}, false))).verdict).toBe("unprovable");
|
|
expect((await proveOwnerGone(old, current({ deploymentId: "old" }))).verdict).toBe("unprovable");
|
|
expect((await proveOwnerGone(old, current({ serviceId: "other" }))).verdict).toBe("unprovable");
|
|
expect((await proveOwnerGone(old, current({ environmentId: "other" }))).verdict).toBe("unprovable");
|
|
expect((await proveOwnerGone(stamp({ host: "old-container" }), current())).verdict).toBe("unprovable");
|
|
});
|
|
|
|
it("reads the deployment and volume from the Railway environment", () => {
|
|
const env = { RAILWAY_PROJECT_ID: "p", RAILWAY_ENVIRONMENT_ID: "e", RAILWAY_SERVICE_ID: "s", RAILWAY_DEPLOYMENT_ID: "d" };
|
|
expect(currentDeploymentIdentity(env)).toEqual({ worker: { provider: "railway", projectId: "p", environmentId: "e",
|
|
serviceId: "s", deploymentId: "d" }, exclusiveVolume: false, volumeId: null });
|
|
expect(currentDeploymentIdentity({ ...env, RAILWAY_VOLUME_ID: "v", RAILWAY_VOLUME_MOUNT_PATH: "/data" })?.exclusiveVolume).toBe(true);
|
|
expect(currentDeploymentIdentity({})).toBeNull();
|
|
// A half-configured volume is not exclusivity: fall back to other proof.
|
|
expect(currentDeploymentIdentity({ ...env, RAILWAY_VOLUME_ID: "v" })?.exclusiveVolume).toBe(false);
|
|
expect(currentDeploymentIdentity({ ...env, RAILWAY_VOLUME_MOUNT_PATH: "/data" })?.exclusiveVolume).toBe(false);
|
|
});
|
|
|
|
it("binds a stamp that recorded its volume to that same volume", async () => {
|
|
const withVolume = (volumeId: string) => probe({ host: "new-container", deployment: {
|
|
worker: { ...worker, deploymentId: "new" }, exclusiveVolume: true, volumeId } });
|
|
expect(await proveOwnerGone(stamp({ worker, volumeId: "vol-a" }), withVolume("vol-a")))
|
|
.toEqual({ verdict: "gone", reason: "deployment_replaced" });
|
|
expect((await proveOwnerGone(stamp({ worker, host: "old", volumeId: "vol-a" }), withVolume("vol-b"))).verdict).toBe("unprovable");
|
|
// A stamp from a deployment that was not volume-exclusive never qualifies.
|
|
expect((await proveOwnerGone(stamp({ worker, host: "old", exclusiveVolume: false }), withVolume("vol-a"))).verdict).toBe("unprovable");
|
|
});
|
|
});
|
|
|
|
describe("proveOwnerGone: remote owners (fenced handoff)", () => {
|
|
const remote = (overrides: Partial<RemoteOwnerStamp> = {}): RemoteOwnerStamp => ({
|
|
kind: "remote", scope: "test", generation: 2, fenceToken: "boot:2:token", provider: "daytona", ...overrides,
|
|
});
|
|
const now = new Date("2026-11-07T12:00:00.000Z");
|
|
const later = new Date(now.getTime() + 30_000);
|
|
const earlier = new Date(now.getTime() - 30_000);
|
|
|
|
it("keeps a held fence alive", async () => {
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 2, leaseExpiresAt: later }, now))
|
|
.toEqual({ verdict: "alive", reason: "fence_held" });
|
|
});
|
|
|
|
it("proves a superseded fence token or generation gone", async () => {
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:3:other", currentGeneration: 2, leaseExpiresAt: later }, now))
|
|
.toEqual({ verdict: "gone", reason: "fence_superseded" });
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: null, currentGeneration: 2, leaseExpiresAt: null }, now))
|
|
.toEqual({ verdict: "gone", reason: "fence_superseded" });
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 3, leaseExpiresAt: later }, now))
|
|
.toEqual({ verdict: "gone", reason: "generation_superseded" });
|
|
});
|
|
|
|
it("never proves an expired fence gone by age alone", async () => {
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 2, leaseExpiresAt: earlier }, now))
|
|
.toEqual({ verdict: "unprovable", reason: "fence_expired_without_provider_proof" });
|
|
});
|
|
|
|
it("uses the provider proof where one exists", async () => {
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 2, leaseExpiresAt: earlier, provider: "stopped" }, now))
|
|
.toEqual({ verdict: "gone", reason: "provider_stopped" });
|
|
expect(await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 2, leaseExpiresAt: earlier, provider: "running" }, now))
|
|
.toEqual({ verdict: "alive", reason: "provider_running" });
|
|
});
|
|
|
|
it("does not consult process liveness for a remote owner", async () => {
|
|
const kill = vi.spyOn(process, "kill");
|
|
await proveOwnerGone(remote(), { currentFenceToken: "boot:2:token", currentGeneration: 2, leaseExpiresAt: earlier }, now);
|
|
expect(kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never ties a provider stop proof to a stamp without a fence token", async () => {
|
|
expect(await proveOwnerGone(remote({ fenceToken: null }), { currentFenceToken: "boot:2:token", currentGeneration: 2,
|
|
leaseExpiresAt: earlier, provider: "stopped" }, now)).toEqual({ verdict: "unprovable", reason: "missing_identity" });
|
|
});
|
|
|
|
it("treats a remote stamp without a fence token as unprovable", async () => {
|
|
expect(await proveOwnerGone(remote({ fenceToken: null }), { currentFenceToken: null, currentGeneration: null, leaseExpiresAt: null }, now))
|
|
.toEqual({ verdict: "unprovable", reason: "missing_identity" });
|
|
});
|
|
});
|