mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
## Thinking Path > - Paperclip manages agents, tasks and their durable native results. > - Changed evidence, task authority or review retirement can require a new status assessment. > - Periodic orphan recovery repeatedly reads history to find these changes. > - This change gives reassessment an owner driven by committed changes and saved deadlines. > - Existing result, assessment, decision and task rows remain durable truth. > - The status committer still verifies current sources and authority before writing. > - Settled or insufficient evidence becomes quiet until a supported producer changes it. ## Linked Issues or Issue Description Refs #17702 (merged), #17679 and #17680. This is slice 50, now based on master. It owns native status reassessment; workspace settlement, retained sessions and other orphan duties remain separate. **What happened?** Periodic recovery repeatedly inspects completed native results. Removing that scan without complete writer coverage or current authority checks could strand new evidence or commit an old result over newer work. **Expected behavior** Reassess from existing durable facts after relevant outer transactions commit. Retain real retry/lease deadlines and restart/drain recovery. Preserve current authority, quality-review and supersession checks. Make no recurring SQL when work is settled or dormant. **Steps to reproduce** Complete a native result awaiting evidence or review, leave it idle, then approve a real work product or retire the selected automatic review. Its saved result should advance once when authorized, or record the existing nonterminal business gate, without a periodic scan. ## What Changed - Extract reassessment and its pure status policy from finalization recovery into their owning modules. Add a scoped worker using the existing delivery coordinator and scheduler; no queue table or migration is added. - Retain the three durable discovery triggers: changed work-product history, authoritative task status/version, or retirement of the selected automatic completion review. Preserve selected-source, ownership, effects, retry and lease fences under the existing committer. - Notify from actual task/work-product, claim release, approval, interaction, OAuth, event evidence and workspace-settlement owners before their transaction writes. Keep provider calls outside short transactions and nested writes behind outer commit. - Scope ordinary milestone/finalization/coalesced-input wakes to an issue/run. Keep genuine company authority restoration explicit. Real Photon and coalescer regressions cover all three response/status queues. - Move the current parent's required quality policy into the owner and apply it to every reassessment that could complete a task. An unmet review records the existing in-progress decision and next action, avoiding an operational retry loop. Preserve conversation/session reset locks and the workspace omission-barrier signal. - Skip reassessment in periodic recovery while preserving explicit/startup recovery and the historical supersession guard before independent workspace work. Document the source contract, producer inventory, failures and qualifications in the linked audit. ## Verification - Broad source `9fddb3f0b1157df32b64fb1f22e9d6e9624470e8` passes **2731 distinct cases in 28 complete files**, with no skips: 2458 producer/owner/authority/workspace/quality cases in 394.43 seconds and all 273 governed-response cases in 53.74 seconds. Nineteen dedicated fresh PostgreSQL 17 databases migrate through 0413; eighteen embedded-only invocations preserve fresh-per-call isolation through the existing recovery helper. All 22 temporary imports restore exactly and all 28 hashes match. - Final runtime source `5ea3b0fd016bf7e63ba82a7044825a46a0cd2082` passes **1211 cases in five complete affected files** in 108.23 seconds after the narrowly reproduced evidence-trigger fix: all 43 worker, 145 corpus, 32 finalization-recovery, 968 native-session and 23 quality-review cases. Four fresh databases plus the quality helper's own fresh fixture use PostgreSQL 17. Its one adapted import restores exactly; all five hashes match. The other 27 broad-run test files are unchanged. These qualifications are source-scoped and not cumulatively double-counted. - Required/optional real regressions reproduce retry loops on retired-review and evidence-approval paths before repair; both become SQL quiet afterward with unchanged assertions/time limits. Controlled removal of only ordinary scope guards reproduces two coarse-hint SQL failures; exact guard restoration passes. Real producer, outer transaction, claim, authority, 103-item backlog, retry and drain cases pass. Photon transport is mocked; callbacks, locks and observed SQL are real. - Normal merges preserve 12661 parent/unrelated entries, 35 owned entries and 21 automatic shared results, with five original conflicts reviewed explicitly. The parent's actual squash reconciliation preserves the entire held child tree. The final quality fix changes only its owner and tests. Fourteen added comments are removed with identical nontrivia tokens; no directives are removed and the final comment check is empty. - Database build, server types/build, boundaries and exact-head migration order pass. The database tree is identical to merged #17702: no new SQL, queue table or snapshot is generated. Earlier benchmarks, host limitations, failed assertions and their repairs remain in `doc/plans/2026-10-26-native-status-reassessment-sleep-audit.md` and `doc/idle-native-status-reassessment.md`. - This qualifies targeted external PostgreSQL behavior, not embedded bootstrap on the semaphore-exhausted host, a full local repository suite, deployed sleep or measured fleet savings. Fresh exact-head CI and review remain required. ## Risks - Notifications retain the process-local supported-writer contract. Independent SQL writers and uncoordinated replicas are not covered by a new transport. - Reassessment competes with current authority changes. Existing row/source locks and retries remain required; arbitrary missing identities have no row to lock, so this is not a general predicate-lock guarantee. - Discovery pages 100 rows but retains outstanding candidates in memory. It remains proportional to backlog size, and cancellation occurs between queries. - Other orphan/workspace/session duties and Cloud sleep eligibility remain unchanged. Missing supported producer coverage could strand dormant work, so the producer audit and real callback/transaction tests remain part of acceptance. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, shell execution and PostgreSQL tests. The runtime does not expose a more specific serving snapshot or context-window size. ## 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 (targeted scope is stated above) - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
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, vi } from "vitest";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
executionWorkspaces,
|
|
projects,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import { eq } from "drizzle-orm";
|
|
import { getWorkspaceOperationLogStore } from "../services/workspace-operation-log-store.js";
|
|
import {
|
|
RUNTIME_CONTROL_STALE_AFTER_MS,
|
|
resetWorkspaceRuntimeControlLocksForTests,
|
|
runExclusiveWorkspaceRuntimeControl,
|
|
workspaceOperationService,
|
|
} from "../services/workspace-operations.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
describe("workspace runtime control serialization", () => {
|
|
afterEach(() => {
|
|
resetWorkspaceRuntimeControlLocksForTests();
|
|
});
|
|
|
|
it("deterministically rejects an overlapping control operation and releases the claim", async () => {
|
|
let releaseFirst!: () => void;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
const first = runExclusiveWorkspaceRuntimeControl({
|
|
executionWorkspaceId: "workspace-1",
|
|
action: "start",
|
|
run: async () => {
|
|
await firstGate;
|
|
return "started";
|
|
},
|
|
});
|
|
|
|
await expect(runExclusiveWorkspaceRuntimeControl({
|
|
executionWorkspaceId: "workspace-1",
|
|
action: "repair",
|
|
run: async () => "repaired",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "workspace_runtime_control_in_progress",
|
|
activeAction: "start",
|
|
requestedAction: "repair",
|
|
},
|
|
});
|
|
|
|
releaseFirst();
|
|
await expect(first).resolves.toBe("started");
|
|
await expect(runExclusiveWorkspaceRuntimeControl({
|
|
executionWorkspaceId: "workspace-1",
|
|
action: "stop",
|
|
run: async () => "stopped",
|
|
})).resolves.toBe("stopped");
|
|
});
|
|
});
|
|
|
|
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_WORKSPACE_OPERATIONS_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping workspace-operation reconciliation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("workspace operation reconciliation", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let logRoot = "";
|
|
let previousLogRoot: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-operation-reconcile-");
|
|
db = createDb(externalUrl ?? tempDb!.connectionString);
|
|
logRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-operation-logs-"));
|
|
previousLogRoot = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH;
|
|
process.env.WORKSPACE_OPERATION_LOG_BASE_PATH = logRoot;
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
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 });
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("terminalizes an orphaned runtime start with an inspectable reconciliation log", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const staleOperationId = randomUUID();
|
|
const unrelatedOperationId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Workspace operation reconciliation",
|
|
issuePrefix: `W${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Runtime reconciliation",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Reconciliation workspace",
|
|
status: "active",
|
|
cwd: "/tmp/reconciliation-workspace",
|
|
});
|
|
|
|
const logHandle = await getWorkspaceOperationLogStore().begin({
|
|
companyId,
|
|
operationId: staleOperationId,
|
|
});
|
|
await db.insert(workspaceOperations).values([
|
|
{
|
|
id: staleOperationId,
|
|
companyId,
|
|
executionWorkspaceId,
|
|
phase: "workspace_provision",
|
|
command: "workspace command start",
|
|
cwd: "/tmp/reconciliation-workspace",
|
|
status: "running",
|
|
logStore: logHandle.store,
|
|
logRef: logHandle.logRef,
|
|
metadata: { action: "start", executionWorkspaceId },
|
|
},
|
|
{
|
|
id: unrelatedOperationId,
|
|
companyId,
|
|
executionWorkspaceId,
|
|
phase: "workspace_provision",
|
|
command: "bash ./scripts/provision-worktree.sh",
|
|
cwd: "/tmp/reconciliation-workspace",
|
|
status: "running",
|
|
metadata: { created: true },
|
|
},
|
|
]);
|
|
|
|
const service = workspaceOperationService(db);
|
|
|
|
// Recovery is bounded and cannot steal a live operation (PAP-17249): a `running` control
|
|
// that has signalled recently is left alone even when it carries no ownership stamp, which
|
|
// is the shape a control recorded by a pre-stamp build has.
|
|
await expect(service.reconcileStaleRuntimeControlOperations(executionWorkspaceId)).resolves.toEqual({
|
|
reconciled: 0,
|
|
operationIds: [],
|
|
});
|
|
expect((await service.getById(staleOperationId))?.status).toBe("running");
|
|
|
|
// Once it has gone quiet for longer than the staleness window, it is terminalized.
|
|
await expect(
|
|
service.reconcileStaleRuntimeControlOperations(executionWorkspaceId, {
|
|
now: new Date(Date.now() + RUNTIME_CONTROL_STALE_AFTER_MS + 5_000),
|
|
}),
|
|
).resolves.toEqual({
|
|
reconciled: 1,
|
|
operationIds: [staleOperationId],
|
|
});
|
|
|
|
const stale = await service.getById(staleOperationId);
|
|
expect(stale).toMatchObject({
|
|
status: "failed",
|
|
metadata: {
|
|
action: "start",
|
|
reconciled: true,
|
|
reconciliationReason: "orphaned_runtime_control",
|
|
},
|
|
});
|
|
expect(stale?.finishedAt).toBeInstanceOf(Date);
|
|
expect(stale?.stderrExcerpt).toContain("no live owner has reported progress");
|
|
expect((await service.readLog(staleOperationId)).content).toContain(
|
|
"no live owner has reported progress",
|
|
);
|
|
|
|
const unrelated = await db
|
|
.select({ status: workspaceOperations.status })
|
|
.from(workspaceOperations)
|
|
.where(eq(workspaceOperations.id, unrelatedOperationId))
|
|
.then((rows) => rows[0]);
|
|
expect(unrelated?.status).toBe("running");
|
|
});
|
|
|
|
it("persists bounded repair phase progress on the operation row and log", async () => {
|
|
const companyId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Workspace repair diagnostics",
|
|
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Repair diagnostics",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Repair diagnostics workspace",
|
|
status: "active",
|
|
cwd: "/tmp/repair-diagnostics-workspace",
|
|
});
|
|
|
|
const operation = await workspaceOperationService(db)
|
|
.createRecorder({ companyId, executionWorkspaceId })
|
|
.recordOperation({
|
|
phase: "workspace_repair",
|
|
command: "workspace command repair",
|
|
cwd: "/tmp/repair-diagnostics-workspace",
|
|
metadata: { action: "repair" },
|
|
run: async (reportProgress) => {
|
|
await reportProgress({
|
|
metadata: {
|
|
repairPhase: "target_backup",
|
|
repairDiagnostics: [{
|
|
phase: "target_backup",
|
|
status: "succeeded",
|
|
at: "2026-08-18T00:00:00.000Z",
|
|
}],
|
|
databaseOnly: true,
|
|
worktreePreserved: true,
|
|
},
|
|
system: "Workspace repair target_backup: succeeded.\n",
|
|
});
|
|
return { status: "succeeded", metadata: { backupRetained: true } };
|
|
},
|
|
});
|
|
|
|
expect(operation).toMatchObject({
|
|
status: "succeeded",
|
|
metadata: {
|
|
action: "repair",
|
|
repairPhase: "target_backup",
|
|
databaseOnly: true,
|
|
worktreePreserved: true,
|
|
backupRetained: true,
|
|
},
|
|
});
|
|
expect((await workspaceOperationService(db).readLog(operation.id)).content).toContain(
|
|
"Workspace repair target_backup: succeeded.",
|
|
);
|
|
});
|
|
|
|
describe("when the operation log file cannot be written", () => {
|
|
// The log file is created, then every append hits a full disk (PAPERCLIP-BACKEND-4H).
|
|
function failLogAppends() {
|
|
const enospc = Object.assign(new Error("ENOSPC: no space left on device, write"), {
|
|
code: "ENOSPC",
|
|
errno: -28,
|
|
syscall: "write",
|
|
});
|
|
const appendFile = fs.appendFile;
|
|
const spy = vi.spyOn(fs, "appendFile").mockImplementation(async (file, ...rest) => {
|
|
if (typeof file === "string" && file.startsWith(logRoot)) throw enospc;
|
|
return appendFile(file, ...rest);
|
|
});
|
|
return { enospc, spy };
|
|
}
|
|
|
|
async function seedCompany() {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Workspace operation full disk",
|
|
issuePrefix: `F${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
return companyId;
|
|
}
|
|
|
|
async function operationRows(companyId: string) {
|
|
return db.select().from(workspaceOperations).where(eq(workspaceOperations.companyId, companyId));
|
|
}
|
|
|
|
it("terminalizes the operation and surfaces the write error", async () => {
|
|
const companyId = await seedCompany();
|
|
const { enospc, spy } = failLogAppends();
|
|
try {
|
|
await expect(
|
|
workspaceOperationService(db)
|
|
.createRecorder({ companyId })
|
|
.recordOperation({
|
|
phase: "worktree_prepare",
|
|
command: "git worktree add",
|
|
cwd: "/tmp/full-disk-workspace",
|
|
run: async () => ({ status: "succeeded", exitCode: 0, stdout: "Preparing worktree\n" }),
|
|
}),
|
|
).rejects.toBe(enospc);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
|
|
const rows = await operationRows(companyId);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ status: "failed", phase: "worktree_prepare" });
|
|
expect(rows[0]?.finishedAt).toBeInstanceOf(Date);
|
|
expect(rows[0]?.stderrExcerpt).toContain("ENOSPC: no space left on device, write");
|
|
});
|
|
|
|
it("keeps the operation's own error instead of the log write error", async () => {
|
|
const companyId = await seedCompany();
|
|
const { spy } = failLogAppends();
|
|
const gitError = new Error("fatal: could not create work tree dir");
|
|
try {
|
|
await expect(
|
|
workspaceOperationService(db)
|
|
.createRecorder({ companyId })
|
|
.recordOperation({
|
|
phase: "worktree_prepare",
|
|
command: "git worktree add",
|
|
cwd: "/tmp/full-disk-workspace",
|
|
run: async () => {
|
|
throw gitError;
|
|
},
|
|
}),
|
|
).rejects.toBe(gitError);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
|
|
const rows = await operationRows(companyId);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ status: "failed" });
|
|
expect(rows[0]?.finishedAt).toBeInstanceOf(Date);
|
|
expect(rows[0]?.stderrExcerpt).toContain("fatal: could not create work tree dir");
|
|
});
|
|
});
|
|
});
|