Files
PaperClipAI/server/src/__tests__/workspace-runtime-control-recovery.test.ts
T
DottaandClaude Opus 5.5 b6f58dc6e2 feat(recovery): settle runtime-control operations of proven-gone owners on the shared path (#20016)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A managed runtime control (start, stop, restart, repair, run) on an
execution workspace writes a `running` operation row that blocks every
other control on that workspace
> - If the server that ran it crashes, the row stays `running` until the
next control request sweeps it. The sweep had its own `kill(pid, 0)`
check, with no process start time, so a reused PID looked alive until 60
s of heartbeat silence passed
> - The durable ownership plan proves owners by identity through one
shared prover and settles them from one reconciler
> - This pull request stamps the owner's boot id and start time, judges
it with `proveOwnerGone`, and registers a settle for the kind
> - The benefit is that a workspace stranded by a crashed server frees
itself in the next reconciler pass, and a reused PID no longer holds it

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Managed runtime-control operation", and the "One fact, many proofs"
table row `ownerProcessIsAlive` workspace-operations.ts).

**What happened?**
`workspace_runtime_control_operation` was a report-only kind with a
bespoke liveness check: `ownerProcessProvenGone` probed the PID with
signal 0 and had no start time. A server restart that reused the PID
kept the operation "alive" until its heartbeat aged out, and nothing
settled the row until another control request arrived.

**Expected behavior**
The owner is proven by the shared prover: a dead or reused PID in the
stamp's PID space is gone. Another host, a stamp without a PID space, or
a live PID without a recorded start time stays unprovable and keeps the
heartbeat lease as its fenced takeover at the next control.

## What Changed

- `workspace-operations.ts`:
- The owner stamp adds the server's `bootId` and `processStartedAt`
(`currentRuntimeControlOwner`, no I/O).
- The new `runtimeControlOperationOwnerStamp` is shared with the
registry.
- The sweep proves the owner with `proveOwnerGone`.
`ownerProcessProvenGone` is deleted.
- The terminal CAS and log append are factored out
(`claimRuntimeControlTermination`,
`appendRuntimeControlReconciliationLog`).
- New `settleRuntimeControlOperation` for the reconciler: it skips
operations this process is driving, requires the same owner as the
proof, CAS-fails the row and writes a `workspace_operation.reconciled`
activity entry.
- `recovery/ownership-registry.ts`: the kind uses the shared stamp and
registers the settle.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `workspace-runtime-control-recovery.test.ts`: 21 pass. The 6 new tests
are in "shared ownership path". 5 fail on `master` and pass here:
  - a reused PID is recovered at once;
  - the reconciler settles a dead owner with activity and frees control;
- an unprovable owner is never settled, and a read-only pass changes
nothing;
  - a re-stamped owner is kept;
  - new operations carry the boot id and start time.

  The live-owner guard passes on both.
- `ownership-reconciliation-report.test.ts` (25),
`workspace-operations-reconciliation.test.ts` (9) and
`execution-workspace-runtime-control-conflict.test.ts` (24) pass.
- The server package typecheck passes.

## Risks

- Rows written before this change have no start time. A live PID on such
a row stays unprovable, and the 60 s heartbeat lease still applies at
the next control. A dead PID is still proof.
- The reconciler now terminalizes these rows. It does so only on `gone`,
and only with the same owner and an `updated_at` CAS.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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 (e.g. `docs/...`, `fix/...`)
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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:07:58 -06:00

671 lines
29 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
companies,
createDb,
executionWorkspaces,
projects,
workspaceOperations,
} from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { setTimeout as delay } from "node:timers/promises";
import { subscribeDatabaseWork } from "@paperclipai/db/work-signals";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import {
RUNTIME_CONTROL_STALE_AFTER_MS,
WorkspaceOperationTimeoutError,
resetWorkspaceRuntimeControlStateForTests,
workspaceOperationService,
workspaceRuntimeControlOwnerIdForTests,
} from "../services/workspace-operations.js";
import { currentControllerProcessStamp, currentProcessSpaceHash, currentProcessStartedAtMs } from "../services/controller-process-identity.js";
import {
createOwnershipReportState,
runOwnershipReconciliationPass,
} from "../services/recovery/ownership-invariant-report.js";
import { OWNABLE_KINDS } from "../services/recovery/ownership-registry.js";
import { getWorkspaceOperationLogStore } from "../services/workspace-operation-log-store.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping runtime-control recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
/**
* Regression coverage for PAP-17249. The adverse report on PAP-17207 left an execution
* workspace with a `running` managed start that no owner was driving, so every managed
* cleanup was rejected with `409 workspace_runtime_control_in_progress` forever.
*
* These cases run against real Postgres because the recovery guarantee is a
* compare-and-swap over `workspace_operations`, not an in-process lock.
*/
describeEmbeddedPostgres("managed runtime-control operation recovery", () => {
let db!: ReturnType<typeof createDb>;
let stopDb: (() => Promise<void>) | null = null;
let logRoot = "";
let previousLogRoot: string | undefined;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("paperclip-runtime-control-recovery-");
stopDb = started.cleanup;
db = createDb(started.connectionString);
logRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-logs-"));
previousLogRoot = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH;
process.env.WORKSPACE_OPERATION_LOG_BASE_PATH = logRoot;
}, 60_000);
afterEach(async () => {
resetWorkspaceRuntimeControlStateForTests();
await db.delete(activityLog);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(projects);
await db.delete(companies);
});
afterAll(async () => {
if (previousLogRoot === undefined) delete process.env.WORKSPACE_OPERATION_LOG_BASE_PATH;
else process.env.WORKSPACE_OPERATION_LOG_BASE_PATH = previousLogRoot;
await fs.rm(logRoot, { recursive: true, force: true });
if (stopDb) await stopDb();
});
async function seedWorkspace() {
const companyId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Runtime control recovery",
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime control recovery",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Lane A",
status: "active",
cwd: "/tmp/runtime-control-lane-a",
});
return { companyId, projectId, executionWorkspaceId };
}
/** Wait for a managed control to have actually persisted its claim row. */
async function waitForRunningOperation(executionWorkspaceId: string) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const row = await db
.select({ id: workspaceOperations.id })
.from(workspaceOperations)
.where(
and(
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
eq(workspaceOperations.status, "running"),
),
)
.then((rows) => rows[0]);
if (row) return row.id;
await delay(20);
}
throw new Error("timed out waiting for the managed control operation row");
}
/** Insert a `running` start the way an abandoned request or dead server process leaves one. */
async function seedRunningStart(input: {
companyId: string;
executionWorkspaceId: string;
/** hostHash defaults to this process's PID space; null omits it (a legacy stamp). */
owner?: { ownerId: string; pid: number; heartbeatAt: Date; hostHash?: string | null;
bootId?: string; processStartedAt?: string };
withLog?: boolean;
startedAt?: Date;
/**
* Let Postgres stamp `started_at`/`updated_at` from the column defaults, at the
* microsecond precision the driver truncates on the way back into JS. This is how every
* row written before managed controls stamped their own timestamps looks.
*/
columnDefaultTimestamps?: boolean;
}) {
const id = randomUUID();
const handle = input.withLog
? await getWorkspaceOperationLogStore().begin({ companyId: input.companyId, operationId: id })
: null;
const startedAt = input.startedAt ?? new Date();
await db.insert(workspaceOperations).values({
id,
companyId: input.companyId,
executionWorkspaceId: input.executionWorkspaceId,
phase: "workspace_provision",
command: "workspace command start",
cwd: "/tmp/runtime-control-lane-a",
status: "running",
logStore: handle?.store ?? null,
logRef: handle?.logRef ?? null,
metadata: {
action: "start",
executionWorkspaceId: input.executionWorkspaceId,
...(input.owner
? {
runtimeControlOwner: {
ownerId: input.owner.ownerId,
pid: input.owner.pid,
...(input.owner.hostHash === null ? {} : { hostHash: input.owner.hostHash ?? currentProcessSpaceHash() }),
action: "start",
heartbeatAt: input.owner.heartbeatAt.toISOString(),
...(input.owner.bootId ? { bootId: input.owner.bootId } : {}),
...(input.owner.processStartedAt ? { processStartedAt: input.owner.processStartedAt } : {}),
},
}
: {}),
},
...(input.columnDefaultTimestamps ? {} : { startedAt, updatedAt: startedAt }),
});
return id;
}
it("recovers a start abandoned by a dead server process and frees managed control", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
// pid 2^22-1 is above every Linux/macOS pid ceiling, so it can never be alive.
const deadPid = 4_194_303;
const staleId = await seedRunningStart({
companyId,
executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: deadPid, heartbeatAt: new Date() },
withLog: true,
});
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 1,
operationIds: [staleId],
});
const recovered = await service.getById(staleId);
expect(recovered).toMatchObject({
status: "failed",
metadata: {
action: "start",
reconciled: true,
reconciliationReason: "orphaned_runtime_control",
},
});
expect(recovered?.finishedAt).toBeInstanceOf(Date);
expect(recovered?.stderrExcerpt).toContain(`the owning server process (pid ${deadPid}) is gone`);
expect((await service.readLog(staleId)).content).toContain("is gone");
// The workspace is controllable again through the ordinary managed path.
await expect(
service.assertRuntimeControlAvailable({ executionWorkspaceId, action: "stop" }),
).resolves.toBeUndefined();
});
it.each([
["another host", "another-host-pid-space"],
["an unreadable PID space", "unknown"],
["a stamp with no recorded PID space", null],
] as const)("never treats a dead local pid as proof for an owner on %s", async (_name, hostHash) => {
const { executionWorkspaceId } = await seedWorkspace().then(async (seeded) => {
await seedRunningStart({ ...seeded,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date(), hostHash } });
return seeded;
});
const service = workspaceOperationService(db);
// Unprovable: inside the staleness window the operation stays live.
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 0, operationIds: [] });
// Its existing TTL still applies once the owner's heartbeat lapses.
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId, {
now: new Date(Date.now() + 60 * 60_000) })).resolves.toMatchObject({ reconciled: 1 });
});
it("refuses to steal a start whose owning process is still alive inside the staleness window", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const liveId = await seedRunningStart({
companyId,
executionWorkspaceId,
// A different server instance, but its pid is this very test process: genuinely alive.
owner: { ownerId: randomUUID(), pid: process.pid, heartbeatAt: new Date() },
});
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 0,
operationIds: [],
});
expect((await service.getById(liveId))?.status).toBe("running");
// ...and a competing control gets the stable conflict, not a silent steal.
await expect(
service.assertRuntimeControlAvailable({ executionWorkspaceId, action: "start" }),
).rejects.toMatchObject({
status: 409,
details: {
code: "workspace_runtime_control_in_progress",
activeAction: "start",
requestedAction: "start",
activeOperationId: liveId,
},
});
});
it("bounds recovery: the same live owner is reclaimed once its heartbeat lapses", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const staleId = await seedRunningStart({
companyId,
executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: process.pid, heartbeatAt: new Date() },
});
const afterWindow = new Date(Date.now() + RUNTIME_CONTROL_STALE_AFTER_MS + 1_000);
await expect(
service.reconcileStaleRuntimeControlOperations(executionWorkspaceId, { now: afterWindow }),
).resolves.toEqual({ reconciled: 1, operationIds: [staleId] });
expect((await service.getById(staleId))?.status).toBe("failed");
});
it("recovers a legacy start with no ownership stamp only after the staleness window", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const legacyId = await seedRunningStart({ companyId, executionWorkspaceId });
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 0,
operationIds: [],
});
await expect(
service.reconcileStaleRuntimeControlOperations(executionWorkspaceId, {
now: new Date(Date.now() + RUNTIME_CONTROL_STALE_AFTER_MS + 1_000),
}),
).resolves.toEqual({ reconciled: 1, operationIds: [legacyId] });
});
it("recovers a start whose timestamps came from the column defaults", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
// Regression guard for the compare-and-swap: `updated_at` written by `defaultNow()` keeps
// microsecond precision, while the driver hands JS a millisecond-truncated Date. An `=`
// CAS against that read never matches, which would make recovery silently no-op on exactly
// the rows it exists to clear — the ones a previous build left `running`.
const legacyId = await seedRunningStart({
companyId,
executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() },
columnDefaultTimestamps: true,
withLog: true,
});
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 1,
operationIds: [legacyId],
});
expect((await service.getById(legacyId))?.status).toBe("failed");
});
it("terminalizes exactly once when two recovery sweeps race the same operation", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const staleId = await seedRunningStart({
companyId,
executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() },
});
const [a, b] = await Promise.all([
service.reconcileStaleRuntimeControlOperations(executionWorkspaceId),
service.reconcileStaleRuntimeControlOperations(executionWorkspaceId),
]);
expect(a.reconciled + b.reconciled).toBe(1);
expect((await service.getById(staleId))?.status).toBe("failed");
});
it("leaves non-runtime-control operations alone", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const provisionId = randomUUID();
await db.insert(workspaceOperations).values({
id: provisionId,
companyId,
executionWorkspaceId,
phase: "workspace_provision",
command: "bash ./scripts/provision-worktree.sh",
cwd: "/tmp/runtime-control-lane-a",
status: "running",
metadata: { created: true },
startedAt: new Date(Date.now() - 60 * 60_000),
updatedAt: new Date(Date.now() - 60 * 60_000),
});
await expect(service.reconcileStaleRuntimeControlOperations()).resolves.toEqual({
reconciled: 0,
operationIds: [],
});
const row = await db
.select({ status: workspaceOperations.status })
.from(workspaceOperations)
.where(eq(workspaceOperations.id, provisionId))
.then((rows) => rows[0]);
expect(row?.status).toBe("running");
});
it("keeps a company's recovery inside its own scope", async () => {
const laneA = await seedWorkspace();
const laneB = await seedWorkspace();
const service = workspaceOperationService(db);
const staleA = await seedRunningStart({
companyId: laneA.companyId,
executionWorkspaceId: laneA.executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() },
});
const staleB = await seedRunningStart({
companyId: laneB.companyId,
executionWorkspaceId: laneB.executionWorkspaceId,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() },
});
await expect(
service.reconcileStaleRuntimeControlOperations(laneA.executionWorkspaceId),
).resolves.toEqual({ reconciled: 1, operationIds: [staleA] });
expect((await service.getById(staleB))?.status).toBe("running");
});
it("holds the workspace while a start is genuinely live in this process, then releases it", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const recorder = service.createRecorder({ companyId, executionWorkspaceId });
let releaseStart!: () => void;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const inFlight = recorder.recordOperation({
phase: "workspace_provision",
command: "workspace command start",
metadata: { action: "start", executionWorkspaceId },
run: async () => {
await startGate;
return { status: "succeeded" as const, exitCode: 0 };
},
});
// The claim exists once the operation row lands, which is the point at which a competing
// control could observe it.
const liveOperationId = await waitForRunningOperation(executionWorkspaceId);
// The live claim is visible to another service instance on the same database, and a
// recovery sweep must not steal it even though this operation has not heartbeated yet.
const competitor = workspaceOperationService(db);
await expect(
competitor.assertRuntimeControlAvailable({ executionWorkspaceId, action: "restart" }),
).rejects.toMatchObject({
status: 409,
details: {
code: "workspace_runtime_control_in_progress",
requestedAction: "restart",
activeOperationId: liveOperationId,
},
});
await expect(
competitor.reconcileStaleRuntimeControlOperations(executionWorkspaceId),
).resolves.toEqual({ reconciled: 0, operationIds: [] });
releaseStart();
const completed = await inFlight;
expect(completed.status).toBe("succeeded");
expect(completed.metadata).toMatchObject({
runtimeControlOwner: { ownerId: workspaceRuntimeControlOwnerIdForTests() },
});
// Terminal operation, so the next managed control proceeds.
await expect(
competitor.assertRuntimeControlAvailable({ executionWorkspaceId, action: "stop" }),
).resolves.toBeUndefined();
});
it("protects an in-process start from recovery even with the staleness window collapsed", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const recorder = service.createRecorder({ companyId, executionWorkspaceId });
const sweeper = workspaceOperationService(db);
let sweeping = true;
let reconciled = 0;
// `staleAfterMs: 0` removes the heartbeat grace period, so the only thing that can keep
// this start alive is the in-process ownership claim. Sweeping continuously across the
// whole recordOperation lifetime also covers the row-insert window.
const sweeps = (async () => {
while (sweeping) {
const result = await sweeper.reconcileStaleRuntimeControlOperations(executionWorkspaceId, {
staleAfterMs: 0,
});
reconciled += result.reconciled;
await delay(2);
}
})();
const operation = await recorder.recordOperation({
phase: "workspace_provision",
command: "workspace command start",
metadata: { action: "start", executionWorkspaceId },
run: async () => {
await delay(120);
return { status: "succeeded" as const, exitCode: 0 };
},
});
sweeping = false;
await sweeps;
expect(reconciled).toBe(0);
expect(operation.status).toBe("succeeded");
expect((await service.getById(operation.id))?.status).toBe("succeeded");
});
it("fails a hung start on its own time budget so no active operation is left behind", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const recorder = service.createRecorder({ companyId, executionWorkspaceId });
await expect(
recorder.recordOperation({
phase: "workspace_provision",
command: "workspace command start",
metadata: { action: "start", executionWorkspaceId },
timeoutMs: 150,
// A start that never settles: the readiness probe that parked on a foreign listener.
run: () => new Promise(() => {}),
}),
).rejects.toBeInstanceOf(WorkspaceOperationTimeoutError);
const rows = await db
.select()
.from(workspaceOperations)
.where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ status: "failed" });
expect(rows[0]?.finishedAt).toBeInstanceOf(Date);
expect(rows[0]?.metadata).toMatchObject({ failureReason: "runtime_control_timeout" });
expect(rows[0]?.stderrExcerpt).toContain("time budget");
// No orphan active operation: a managed stop or retry goes straight through.
await expect(
service.assertRuntimeControlAvailable({ executionWorkspaceId, action: "stop" }),
).resolves.toBeUndefined();
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
reconciled: 0,
operationIds: [],
});
});
it("lets a managed retry succeed immediately after a failed start", async () => {
const { companyId, executionWorkspaceId } = await seedWorkspace();
const service = workspaceOperationService(db);
const recorder = service.createRecorder({ companyId, executionWorkspaceId });
await expect(
recorder.recordOperation({
phase: "workspace_provision",
command: "workspace command start",
metadata: { action: "start", executionWorkspaceId },
run: async () => {
throw new Error("Runtime service \"app\" could not start because port 42003 is already in use by pid 1234");
},
}),
).rejects.toThrow(/already in use/);
await expect(
service.assertRuntimeControlAvailable({ executionWorkspaceId, action: "start" }),
).resolves.toBeUndefined();
const retry = await recorder.recordOperation({
phase: "workspace_provision",
command: "workspace command start",
metadata: { action: "start", executionWorkspaceId },
run: async () => ({ status: "succeeded" as const, exitCode: 0 }),
});
expect(retry.status).toBe("succeeded");
const statuses = await db
.select({ status: workspaceOperations.status })
.from(workspaceOperations)
.where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId));
expect(statuses.map((row) => row.status).sort()).toEqual(["failed", "succeeded"]);
});
describe("shared ownership path (plan Phase 2)", () => {
const kind = OWNABLE_KINDS.find((entry) => entry.id === "workspace_runtime_control_operation")!;
// The server resolves its own start time at boot; the stamp reads it without I/O.
beforeAll(async () => { await currentProcessStartedAtMs(); });
const pass = (record = true) => runOwnershipReconciliationPass(db, { state: createOwnershipReportState(),
kinds: [kind], record });
// A previous server whose PID this test process now reuses: same PID space,
// same PID, but it started long before this process did.
const reusedPidOwner = () => ({ ownerId: randomUUID(), pid: process.pid, heartbeatAt: new Date(),
bootId: randomUUID(), processStartedAt: "2000-01-01T00:00:00.000Z" });
it("recovers at once a start whose owner's PID now belongs to a later process", async () => {
const seeded = await seedWorkspace();
const staleId = await seedRunningStart({ ...seeded, owner: reusedPidOwner() });
const service = workspaceOperationService(db);
await expect(service.reconcileStaleRuntimeControlOperations(seeded.executionWorkspaceId)).resolves.toEqual({
reconciled: 1, operationIds: [staleId] });
});
it("keeps a start whose owner PID and start time still match a live process", async () => {
const seeded = await seedWorkspace();
const controller = currentControllerProcessStamp();
expect(controller.processStartedAt).toBeTruthy();
await seedRunningStart({ ...seeded, owner: { ownerId: randomUUID(), pid: process.pid, heartbeatAt: new Date(),
bootId: randomUUID(), processStartedAt: controller.processStartedAt! } });
await expect(workspaceOperationService(db).reconcileStaleRuntimeControlOperations(seeded.executionWorkspaceId))
.resolves.toEqual({ reconciled: 0, operationIds: [] });
const report = await pass();
expect(report.kinds[0]).toMatchObject({ inProgress: 1, settled: 0 });
});
it("settles a start whose owner server is gone without waiting for the next control", async () => {
const seeded = await seedWorkspace();
const staleId = await seedRunningStart({ ...seeded, withLog: true,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() } });
const report = await pass();
expect(report.kinds[0]).toMatchObject({ kind: "workspace_runtime_control_operation", inProgress: 1, settled: 1 });
const row = await workspaceOperationService(db).getById(staleId);
expect(row).toMatchObject({ status: "failed", metadata: { reconciled: true,
reconciliationReason: "orphaned_runtime_control", reconciledBy: "ownership-reconciler", ownerProof: "pid_dead" } });
expect(row?.stderrExcerpt).toContain("(pid 4194303) is gone (pid_dead)");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, staleId));
expect(activity).toMatchObject([{ action: "workspace_operation.reconciled", actorId: "ownership-reconciler" }]);
await expect(workspaceOperationService(db).assertRuntimeControlAvailable({
executionWorkspaceId: seeded.executionWorkspaceId, action: "start" })).resolves.toBeUndefined();
});
it("never settles an owner it cannot prove, and a read-only pass changes nothing", async () => {
const seeded = await seedWorkspace();
const otherHost = await seedRunningStart({ ...seeded,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date(0), hostHash: "another-host-pid-space" } });
const proven = await seedRunningStart({ ...seeded,
owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() } });
const readOnly = await pass(false);
expect(readOnly.kinds[0]).toMatchObject({ inProgress: 2, settled: 0 });
expect((await workspaceOperationService(db).getById(proven))?.status).toBe("running");
await pass();
// Even with a long-lapsed heartbeat, age is not proof on the shared path.
expect((await workspaceOperationService(db).getById(otherHost))?.status).toBe("running");
expect((await workspaceOperationService(db).getById(proven))?.status).toBe("failed");
});
it("keeps an operation whose owner re-stamped after the proof", async () => {
const seeded = await seedWorkspace();
const id = await seedRunningStart({ ...seeded, owner: { ownerId: randomUUID(), pid: 4_194_303, heartbeatAt: new Date() } });
const [record] = (await kind.listInProgress!(db, 10, 0)).records;
const controller = currentControllerProcessStamp();
await db.update(workspaceOperations).set({ metadata: { action: "start", runtimeControlOwner: {
ownerId: randomUUID(), pid: process.pid, hostHash: controller.hostHash, bootId: controller.bootId,
processStartedAt: controller.processStartedAt, action: "start", heartbeatAt: new Date().toISOString() } } })
.where(eq(workspaceOperations.id, id));
await expect(kind.settle!(db, record!, { now: new Date(), proof: { verdict: "gone", reason: "pid_dead" } }))
.resolves.toEqual({ settled: false, reason: "owner_changed_since_proof" });
expect((await workspaceOperationService(db).getById(id))?.status).toBe("running");
});
it("wakes the ownership worker when a runtime control starts, before its row is visible", async () => {
const seeded = await seedWorkspace();
const recorder = workspaceOperationService(db).createRecorder({ companyId: seeded.companyId,
executionWorkspaceId: seeded.executionWorkspaceId });
const hints: string[] = [];
const stop = subscribeDatabaseWork(db, DELIVERY_QUEUES.ownershipInvariantReport, (event, eventHints) => {
if (event === "settled") hints.push(...(eventHints ?? []));
});
let operationId: string | undefined;
try {
await recorder.recordOperation({ phase: "workspace_provision", metadata: { action: "start" }, run: async () => {
operationId = (await db.select().from(workspaceOperations).where(eq(workspaceOperations.status, "running")))[0]?.id;
return { status: "succeeded" };
} });
for (let attempt = 0; attempt < 50 && !hints.some(hint => operationId && hint.includes(operationId)); attempt += 1) {
await delay(20);
}
} finally { stop(); }
expect(operationId).toBeTruthy();
expect(hints.some(hint => hint.includes(operationId!))).toBe(true);
});
it("stamps new operations with the server's boot id and start time", async () => {
const seeded = await seedWorkspace();
const service = workspaceOperationService(db);
const recorder = service.createRecorder({ companyId: seeded.companyId, executionWorkspaceId: seeded.executionWorkspaceId });
let seen: Record<string, unknown> | null = null;
await recorder.recordOperation({ phase: "workspace_provision", metadata: { action: "start" }, run: async () => {
const [row] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.status, "running"));
seen = (row?.metadata as Record<string, unknown>).runtimeControlOwner as Record<string, unknown>;
return { status: "succeeded" };
} });
const controller = currentControllerProcessStamp();
expect(seen).toMatchObject({ pid: process.pid, hostHash: controller.hostHash, bootId: controller.bootId,
processStartedAt: controller.processStartedAt });
});
});
});