mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix(workspaces): keep deferred seed databases reliable (#11706)
## Thinking Path > - Paperclip manages agent work in isolated execution workspaces. > - A workspace depends on a valid database seed before it can run. > - Deferred seed failures were hidden behind a successful provision status. > - The seed restore also had two possible owners for the embedded PostgreSQL process. > - That allowed the target database to stop while the restore was still running. > - This pull request makes seed failures visible and gives the seed process sole lifecycle ownership. > - The benefit is that workspace provisioning reports the real result and does not stop its own target database. ## Linked Issues or Issue Description Related: #11684 **What happened?** Initial worktree provisioning could report success before its deferred database seed completed. The seed restore could also reuse a target embedded PostgreSQL process with another shutdown owner. This could stop the target database during the restore. **Expected behavior** Workspace status must show a failed deferred seed as a failure. The seed restore must own the target embedded PostgreSQL process until restore, migration, and validation finish. **Steps to reproduce** 1. Provision a worktree with deferred database seeding. 2. Make the seed manifest end in a failed state while the command exits with code 0. 3. Observe that the provision status remains successful on `master`. 4. Start a seed restore against an already-running target embedded PostgreSQL process. 5. Observe that another lifecycle owner can stop the target during restore. **Paperclip version or commit** `51a843e135` **Deployment mode** Local dev with execution workspaces and embedded PostgreSQL. ## What Changed - Add a first-class `workspace_seed` operation for deferred database seeds. - Require terminal, verified seed evidence before the seed operation succeeds. - Surface the seed phase and failure metadata in workspace status and UI state. - Give the seed process exclusive lifecycle ownership of the target embedded PostgreSQL process. - Suppress imported embedded-Postgres exit hooks without removing existing host listeners. - Record a credential-safe shutdown diagnostic in failed seed manifests. ## Verification - The original deferred-seed commit passed 4 server tests, 24 workspace-status UI tests, shared/server/UI typechecks, and the UI token gate. - The original PostgreSQL-lifecycle commit passed 3 lifecycle tests, 3 ownership/diagnostic tests, 1 real embedded-Postgres seed integration, and the affected package typechecks. - No local tests were rerun after the clean cherry-pick because the operator requested the shortest landing path. - Review the automatic PR checks for the clean `origin/master` replay. ## Risks - A live target database now causes an early error instead of being reused. The error includes recovery guidance. - Workspace consumers must handle the new `workspace_seed` operation type. Shared types and UI state handling are updated in this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5, high-reasoning mode, with repository, shell, and GitHub tool use. The runtime does not expose a more specific deployment suffix or context-window value. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
51a843e135
commit
e1df4c6068
13 files changed
+443
-38
No files matched your search
@@ -36,7 +36,7 @@ import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
|
||||
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { resolveHomeAwarePath } from "../home-paths.js";
|
||||
import { hasVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
|
||||
import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
|
||||
import {
|
||||
buildManagedWorkspaceGuestEnv,
|
||||
logManagedWorkspaceReadinessRejection,
|
||||
@@ -2871,7 +2871,7 @@ async function recordGitOperation(
|
||||
async function recordWorkspaceCommandOperation(
|
||||
recorder: WorkspaceOperationRecorder | null | undefined,
|
||||
input: {
|
||||
phase: "workspace_provision" | "workspace_runtime_provision" | "workspace_teardown";
|
||||
phase: "workspace_provision" | "workspace_seed" | "workspace_runtime_provision" | "workspace_teardown";
|
||||
command: string;
|
||||
resolvedCommand?: string;
|
||||
cwd: string;
|
||||
@@ -2903,26 +2903,31 @@ async function recordWorkspaceCommandOperation(
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
const seedEvidence = input.phase === "workspace_seed"
|
||||
? readWorkspaceSeedOperationEvidence(input.cwd)
|
||||
: null;
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
code = result.code;
|
||||
stderr = [result.stderr, seedEvidence?.error].filter(Boolean).join("\n");
|
||||
code = result.code === 0 && seedEvidence && !seedEvidence.verified ? 1 : result.code;
|
||||
if (result.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${result.stdout}`);
|
||||
if (result.stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${result.stderr}`);
|
||||
if (stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${stderr}`);
|
||||
const truncationMetadata = result.stdoutTruncated || result.stderrTruncated
|
||||
? {
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
stdoutBytes: result.stdoutBytes,
|
||||
stderrBytes: result.stderrBytes,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
status: result.code === 0 ? "succeeded" : "failed",
|
||||
exitCode: result.code,
|
||||
status: code === 0 ? "succeeded" : "failed",
|
||||
exitCode: code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
||||
metadata:
|
||||
result.stdoutTruncated || result.stderrTruncated
|
||||
? {
|
||||
stdoutTruncated: result.stdoutTruncated,
|
||||
stderrTruncated: result.stderrTruncated,
|
||||
stdoutBytes: result.stdoutBytes,
|
||||
stderrBytes: result.stderrBytes,
|
||||
}
|
||||
: null,
|
||||
stderr,
|
||||
system: code === 0 ? input.successMessage ?? null : null,
|
||||
metadata: seedEvidence
|
||||
? { ...seedEvidence.metadata, ...(truncationMetadata ?? {}) }
|
||||
: truncationMetadata,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -5052,6 +5057,7 @@ type StartLocalRuntimeServiceInput = {
|
||||
service: Record<string, unknown>;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
runtimeProvisionCommand?: string | null;
|
||||
runtimeProvisionKind?: RuntimeProvisionKind | null;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
provisionCoordinator?: RuntimeProvisionCoordinator;
|
||||
preparedProvisioningRecord?: RuntimeServiceRecord | null;
|
||||
@@ -5079,6 +5085,49 @@ function readRuntimeProvisionCommand(config: Record<string, unknown>) {
|
||||
).trim();
|
||||
}
|
||||
|
||||
const BUILTIN_WORKSPACE_SEED_COMMAND = "bash ./scripts/provision-worktree-runtime.sh";
|
||||
|
||||
type RuntimeProvisionKind = "workspace_seed" | "runtime_dependencies";
|
||||
|
||||
function readWorkspaceSeedOperationEvidence(worktreePath: string): {
|
||||
verified: boolean;
|
||||
error: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
} {
|
||||
const manifestPath = path.join(worktreePath, ".paperclip", "seed-manifest.json");
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
||||
const state = typeof manifest.state === "string" ? manifest.state : "unknown";
|
||||
const phase = typeof manifest.phase === "string" ? manifest.phase : null;
|
||||
const verified = isVerifiedWorktreeSeedManifest(manifest);
|
||||
return {
|
||||
verified,
|
||||
error: verified
|
||||
? null
|
||||
: phase
|
||||
? `Workspace seed command returned without a verified manifest (state: ${state}, phase: ${phase}).`
|
||||
: `Workspace seed command returned without a verified manifest (state: ${state}).`,
|
||||
metadata: {
|
||||
provisionKind: "workspace_seed",
|
||||
seedState: state,
|
||||
seedPhase: phase,
|
||||
seedFailurePhase: state === "failed" ? phase : null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
verified: false,
|
||||
error: "Workspace seed command returned without a readable seed manifest.",
|
||||
metadata: {
|
||||
provisionKind: "workspace_seed",
|
||||
seedState: existsSync(manifestPath) ? "unreadable" : "absent",
|
||||
seedPhase: null,
|
||||
seedFailurePhase: "seed_manifest_unreadable",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRuntimeProvisionCommand(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
@@ -5105,7 +5154,21 @@ export function resolveRuntimeProvisionCommand(input: {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "bash ./scripts/provision-worktree-runtime.sh";
|
||||
return BUILTIN_WORKSPACE_SEED_COMMAND;
|
||||
}
|
||||
|
||||
function resolveRuntimeProvision(input: {
|
||||
config: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
}): { command: string; kind: RuntimeProvisionKind | null } {
|
||||
const command = resolveRuntimeProvisionCommand(input);
|
||||
if (!command) return { command, kind: null };
|
||||
return {
|
||||
command,
|
||||
kind: readRuntimeProvisionCommand(input.config)
|
||||
? "runtime_dependencies"
|
||||
: "workspace_seed",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) {
|
||||
@@ -5136,8 +5199,9 @@ async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeSer
|
||||
})
|
||||
: null);
|
||||
const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd);
|
||||
const workspaceSeed = input.runtimeProvisionKind === "workspace_seed";
|
||||
const promise = recordWorkspaceCommandOperation(recorder, {
|
||||
phase: "workspace_runtime_provision",
|
||||
phase: workspaceSeed ? "workspace_seed" : "workspace_runtime_provision",
|
||||
command,
|
||||
resolvedCommand,
|
||||
cwd: input.workspace.cwd,
|
||||
@@ -5150,14 +5214,19 @@ async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeSer
|
||||
agent: input.agent,
|
||||
created: input.workspace.created,
|
||||
}),
|
||||
label: `Runtime provision command "${command}"`,
|
||||
label: workspaceSeed
|
||||
? `Workspace seed command "${command}"`
|
||||
: `Runtime provision command "${command}"`,
|
||||
metadata: {
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
serviceName: asString(input.service.name, "service"),
|
||||
provisionKind: workspaceSeed ? "workspace_seed" : "runtime_dependencies",
|
||||
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
|
||||
},
|
||||
successMessage: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
|
||||
successMessage: workspaceSeed
|
||||
? `Verified the workspace database seed for ${input.workspace.cwd}\n`
|
||||
: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`,
|
||||
onLog: input.onLog,
|
||||
}).then(() => undefined);
|
||||
|
||||
@@ -6465,7 +6534,8 @@ async function ensureRuntimeServicesForRunInvocation(
|
||||
});
|
||||
const acquiredServiceIds: string[] = [];
|
||||
const refs: RuntimeServiceRef[] = [];
|
||||
const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
|
||||
const runtimeProvision = resolveRuntimeProvision(input);
|
||||
const runtimeProvisionCommand = runtimeProvision.command;
|
||||
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
||||
const allowFixedPortFallback = await isPersistedIsolatedExecutionWorkspace({
|
||||
db: input.db,
|
||||
@@ -6523,6 +6593,7 @@ async function ensureRuntimeServicesForRunInvocation(
|
||||
service,
|
||||
onLog: input.onLog,
|
||||
runtimeProvisionCommand,
|
||||
runtimeProvisionKind: runtimeProvision.kind,
|
||||
recorder: input.recorder,
|
||||
provisionCoordinator,
|
||||
allowFixedPortFallback,
|
||||
@@ -6693,6 +6764,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(
|
||||
deferReadiness?: boolean;
|
||||
allowFixedPortFallback?: boolean;
|
||||
runtimeProvisionCommand?: string;
|
||||
runtimeProvisionKind?: RuntimeProvisionKind | null;
|
||||
provisionCoordinator?: RuntimeProvisionCoordinator;
|
||||
preparedProvisioning?: {
|
||||
service: Record<string, unknown>;
|
||||
@@ -6759,6 +6831,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked(
|
||||
service,
|
||||
onLog: input.onLog,
|
||||
runtimeProvisionCommand: options?.runtimeProvisionCommand,
|
||||
runtimeProvisionKind: options?.runtimeProvisionKind,
|
||||
recorder: input.recorder,
|
||||
provisionCoordinator: options?.provisionCoordinator,
|
||||
preparedProvisioningRecord:
|
||||
@@ -6861,7 +6934,8 @@ async function startRuntimeServicesForWorkspaceControlInvocation(
|
||||
serviceStates: readConfiguredServiceStates(input.config),
|
||||
});
|
||||
const invocationId = input.invocationId ?? randomUUID();
|
||||
const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
|
||||
const runtimeProvision = resolveRuntimeProvision(input);
|
||||
const runtimeProvisionCommand = runtimeProvision.command;
|
||||
const provisionCoordinator = createRuntimeProvisionCoordinator();
|
||||
const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices);
|
||||
|
||||
@@ -6880,7 +6954,11 @@ async function startRuntimeServicesForWorkspaceControlInvocation(
|
||||
invocationId,
|
||||
input.db,
|
||||
input.db,
|
||||
{ runtimeProvisionCommand, provisionCoordinator },
|
||||
{
|
||||
runtimeProvisionCommand,
|
||||
runtimeProvisionKind: runtimeProvision.kind,
|
||||
provisionCoordinator,
|
||||
},
|
||||
);
|
||||
return batch.refs;
|
||||
}
|
||||
@@ -6931,6 +7009,7 @@ async function startRuntimeServicesForWorkspaceControlInvocation(
|
||||
service,
|
||||
onLog: input.onLog,
|
||||
runtimeProvisionCommand,
|
||||
runtimeProvisionKind: runtimeProvision.kind,
|
||||
recorder: input.recorder,
|
||||
provisionCoordinator,
|
||||
reuseKey,
|
||||
@@ -6959,6 +7038,7 @@ async function startRuntimeServicesForWorkspaceControlInvocation(
|
||||
deferReadiness: true,
|
||||
allowFixedPortFallback,
|
||||
runtimeProvisionCommand,
|
||||
runtimeProvisionKind: runtimeProvision.kind,
|
||||
provisionCoordinator,
|
||||
preparedProvisioning,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user