mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix(workspaces): make managed runtimes reliable across restarts (#11740)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Execution workspaces need isolated databases, ports, and runtime services > - Concurrent workspaces could reuse ports or lose service ownership after a restart > - A markerless worktree also needed seed recovery, but normal markerless instances still needed to boot > - This pull request makes seed, port, and service ownership state explicit and recoverable > - It also checks live process and listener identity before it reclaims shared resources > - The benefit is reliable workspace startup, restart, adoption, and concurrent provisioning ## Linked Issues or Issue Description **What happened?** Managed workspaces could lose runtime service ownership after a control-plane restart. Concurrent worktrees could also reuse a port when their parent paths differed. A seed recovery change made every markerless instance resolve a worktree seed source, so normal instances without a source could not start. **Expected behavior** Paperclip must preserve healthy managed services across restarts. It must reserve unique ports across worktree parents. It must provision a registered markerless worktree, but it must skip seed work for a normal markerless instance. **Steps to reproduce** 1. Start two managed worktrees under different parent paths at the same time. 2. Restart the control plane while a managed service stays alive. 3. Start Paperclip with a config that has no seed markers and no registered worktree source. 4. Observe duplicate port selection, lost service adoption, or a seed-source startup error. **Paperclip version or commit** Current `master` plus the workspace runtime reliability changes in this pull request. **Deployment mode** Local development with managed execution workspaces and embedded Postgres. ## What Changed - Added a shared port registry with lease heartbeats, process identity checks, and live listener probes. - Reserved worktree ports across custom parent paths and repaired duplicate legacy assignments. - Preserved and adopted healthy managed services across control-plane restarts. - Reconciled guest bind modes and verified listener ownership before termination or reuse. - Provisioned registered markerless worktree databases and kept normal markerless instance startup as a no-op. - Added CLI, shared, server, and shell regression tests for seed, port, listener, restart, and adoption behavior. - Updated the worktree development documentation. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts --reporter=verbose` — 63 tests passed. - `pnpm exec vitest run packages/shared/src/worktree-port-registry.test.ts --reporter=verbose` — 5 tests passed. - Focused runtime Vitest set — 199 tests passed across 37 suites. - `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs` — 10 tests passed. - `git diff --check` passed. ## Risks - Port reservation now depends on lease and process identity data. The fallback listener probe prevents early reclamation when process metadata is incomplete. - Runtime adoption is stricter about bind and owner identity. The tests cover healthy adoption, stale records, PID reuse, and unrelated listeners. - Markerless seed detection now separates registered worktrees from normal instances. The tests cover both paths. - There are no database schema migrations. > 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 with the `gpt-5` model family. The serving snapshot and context-window size are not exposed. The agent used reasoning, repository tools, code execution, and test execution. ## 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 - [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: Dev Agent <dev@paperclip.ing>
This commit is contained in:
21 files changed
+2406
-345
No files matched your search
+272
-85
@@ -22,7 +22,15 @@ import { Readable } from "node:stream";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { resolveCanonicalWorktreeSeedSource } from "@paperclipai/shared/worktree-seed-source";
|
||||
import {
|
||||
resolveCanonicalWorktreeSeedSource,
|
||||
resolveRegisteredWorktreeSeedSource,
|
||||
} from "@paperclipai/shared/worktree-seed-source";
|
||||
import {
|
||||
readWorktreePortRegistry,
|
||||
withWorktreePortRegistryLock,
|
||||
writeWorktreePortRegistry,
|
||||
} from "@paperclipai/shared/worktree-port-registry";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
@@ -239,10 +247,19 @@ type SeedWorktreeDatabase = typeof seedWorktreeDatabase;
|
||||
|
||||
export type EnsureWorktreeSeededResult = {
|
||||
seeded: boolean;
|
||||
reason: "seeded" | "verified_manifest" | "complete_marker" | "legacy_unmarked";
|
||||
reason:
|
||||
| "seeded"
|
||||
| "verified_manifest"
|
||||
| "complete_marker"
|
||||
| "legacy_unmarked"
|
||||
| "legacy_database";
|
||||
details?: SeedWorktreeDatabaseResult;
|
||||
};
|
||||
|
||||
export type LegacyWorktreeDatabaseEvidence = {
|
||||
migrationRevision: string;
|
||||
};
|
||||
|
||||
export type SeededWorktreeExecutionQuarantineSummary = {
|
||||
disabledTimerHeartbeats: number;
|
||||
resetRunningAgents: number;
|
||||
@@ -584,13 +601,24 @@ function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
|
||||
return path.resolve(repoRoot, ".paperclip", "worktrees");
|
||||
}
|
||||
|
||||
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
|
||||
function collectClaimedWorktreePorts(
|
||||
homeDir: string,
|
||||
currentInstanceId: string,
|
||||
cwd: string,
|
||||
registeredConfigPaths: Iterable<string> = [],
|
||||
): {
|
||||
serverPorts: Set<number>;
|
||||
databasePorts: Set<number>;
|
||||
} {
|
||||
const serverPorts = new Set<number>();
|
||||
const databasePorts = new Set<number>();
|
||||
const configPaths = new Set<string>();
|
||||
for (const configPath of registeredConfigPaths) {
|
||||
const resolvedConfigPath = path.resolve(configPath);
|
||||
if (resolvedConfigPath !== path.resolve(cwd, ".paperclip", "config.json") && existsSync(resolvedConfigPath)) {
|
||||
configPaths.add(resolvedConfigPath);
|
||||
}
|
||||
}
|
||||
const instancesDir = path.resolve(homeDir, "instances");
|
||||
if (existsSync(instancesDir)) {
|
||||
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
@@ -620,8 +648,13 @@ function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string,
|
||||
if (config?.server.port) {
|
||||
serverPorts.add(config.server.port);
|
||||
}
|
||||
if (config?.database.mode === "embedded-postgres") {
|
||||
databasePorts.add(config.database.embeddedPostgresPort);
|
||||
const databasePort = config?.database.embeddedPostgresPort;
|
||||
if (
|
||||
typeof databasePort === "number" &&
|
||||
Number.isInteger(databasePort) &&
|
||||
databasePort > 0
|
||||
) {
|
||||
databasePorts.add(databasePort);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sibling configs.
|
||||
@@ -1423,6 +1456,12 @@ type WorktreeSeedValidationExpectation = {
|
||||
representativeIssueId: string;
|
||||
};
|
||||
|
||||
export function requiresWorktreeSeedCredentialAccount(
|
||||
deploymentMode: PaperclipConfig["server"]["deploymentMode"],
|
||||
): boolean {
|
||||
return deploymentMode === "authenticated";
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedMigrationRevision(
|
||||
migrationState: Awaited<ReturnType<typeof inspectMigrations>>,
|
||||
requirement: "sourcePrefix" | "upToDate",
|
||||
@@ -1452,12 +1491,67 @@ export function resolveWorktreeSeedMigrationRevision(
|
||||
return migrationRevision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markerless worktrees predate the versioned seed manifest. Adopt one only
|
||||
* after proving that its configured database already has a compatible
|
||||
* migration journal and the core Paperclip tables. The physical PG_VERSION
|
||||
* check prevents this read-only probe from initializing a missing embedded
|
||||
* database and then mistaking that empty cluster for legacy evidence.
|
||||
*/
|
||||
export async function inspectLegacyWorktreeDatabase(
|
||||
configPath: string,
|
||||
): Promise<LegacyWorktreeDatabaseEvidence | null> {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) return null;
|
||||
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
let db: ReturnType<typeof createDb> | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||
if (!existsSync(path.join(dataDir, "PG_VERSION"))) return null;
|
||||
embeddedHandle = await ensureEmbeddedPostgres(dataDir, config.database.embeddedPostgresPort);
|
||||
}
|
||||
|
||||
const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port);
|
||||
const migrationRevision = resolveWorktreeSeedMigrationRevision(
|
||||
await inspectMigrations(connectionString),
|
||||
"sourcePrefix",
|
||||
);
|
||||
db = createDb(connectionString);
|
||||
await Promise.all([
|
||||
db.select({ id: authUsers.id }).from(authUsers).limit(1),
|
||||
db.select({ id: companies.id }).from(companies).limit(1),
|
||||
db.select({ id: issues.id }).from(issues).limit(1),
|
||||
]);
|
||||
return { migrationRevision };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await db?.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectVerifiedSeedDatabase(
|
||||
connectionString: string,
|
||||
expected?: WorktreeSeedValidationExpectation,
|
||||
migrationRequirement: "sourcePrefix" | "upToDate" = "upToDate",
|
||||
requiredCompanyId?: string,
|
||||
options: {
|
||||
deploymentMode: PaperclipConfig["server"]["deploymentMode"];
|
||||
expected?: WorktreeSeedValidationExpectation;
|
||||
migrationRequirement?: "sourcePrefix" | "upToDate";
|
||||
requiredCompanyId?: string;
|
||||
},
|
||||
): Promise<{ summary: WorktreeSeedValidationSummary; expectation: WorktreeSeedValidationExpectation }> {
|
||||
const {
|
||||
deploymentMode,
|
||||
expected,
|
||||
migrationRequirement = "upToDate",
|
||||
requiredCompanyId,
|
||||
} = options;
|
||||
const requiresCredentialAccount = requiresWorktreeSeedCredentialAccount(deploymentMode);
|
||||
const migrationState = await inspectMigrations(connectionString);
|
||||
const migrationRevision = resolveWorktreeSeedMigrationRevision(
|
||||
migrationState,
|
||||
@@ -1499,13 +1593,9 @@ async function inspectVerifiedSeedDatabase(
|
||||
instanceUserRoles,
|
||||
and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")),
|
||||
)
|
||||
.innerJoin(
|
||||
.leftJoin(
|
||||
authAccounts,
|
||||
and(
|
||||
eq(authAccounts.userId, authUsers.id),
|
||||
sql`length(trim(${authAccounts.providerId})) > 0`,
|
||||
sql`length(trim(${authAccounts.accountId})) > 0`,
|
||||
),
|
||||
eq(authAccounts.userId, authUsers.id),
|
||||
)
|
||||
.innerJoin(
|
||||
companyMemberships,
|
||||
@@ -1518,12 +1608,20 @@ async function inspectVerifiedSeedDatabase(
|
||||
.where(and(
|
||||
expected ? eq(authUsers.id, expected.adminUserId) : undefined,
|
||||
requiredCompanyId ? eq(companyMemberships.companyId, requiredCompanyId) : undefined,
|
||||
requiresCredentialAccount
|
||||
? and(
|
||||
sql`length(trim(${authAccounts.providerId})) > 0`,
|
||||
sql`length(trim(${authAccounts.accountId})) > 0`,
|
||||
)
|
||||
: undefined,
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!admin) {
|
||||
throw new Error(
|
||||
"No auth user has a non-empty credential account, instance-admin role, and active company membership.",
|
||||
requiresCredentialAccount
|
||||
? "No auth user has a non-empty credential account, instance-admin role, and active company membership. Authenticated worktree seeding requires a credential-backed instance administrator."
|
||||
: "No auth user has an instance-admin role and active company membership for local-trusted worktree seeding.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1557,7 +1655,7 @@ async function inspectVerifiedSeedDatabase(
|
||||
};
|
||||
if (
|
||||
summary.authUserCount < 1
|
||||
|| summary.credentialAccountCount < 1
|
||||
|| (requiresCredentialAccount && summary.credentialAccountCount < 1)
|
||||
|| summary.instanceAdminCount < 1
|
||||
|| summary.activeMembershipCount < 1
|
||||
|| summary.companyCount < 1
|
||||
@@ -1613,9 +1711,11 @@ async function seedWorktreeDatabase(input: {
|
||||
input.onPhase?.("source_validation", "started");
|
||||
const sourceValidation = await inspectVerifiedSeedDatabase(
|
||||
sourceConnectionString,
|
||||
undefined,
|
||||
"sourcePrefix",
|
||||
input.expectedCompanyId,
|
||||
{
|
||||
deploymentMode: input.sourceConfig.server.deploymentMode,
|
||||
migrationRequirement: "sourcePrefix",
|
||||
requiredCompanyId: input.expectedCompanyId,
|
||||
},
|
||||
);
|
||||
input.onPhase?.(
|
||||
"source_validation",
|
||||
@@ -1684,7 +1784,10 @@ async function seedWorktreeDatabase(input: {
|
||||
input.onPhase?.("post_restore_validation", "started");
|
||||
const targetValidation = await inspectVerifiedSeedDatabase(
|
||||
targetConnectionString,
|
||||
sourceValidation.expectation,
|
||||
{
|
||||
deploymentMode: input.targetConfig.server.deploymentMode,
|
||||
expected: sourceValidation.expectation,
|
||||
},
|
||||
);
|
||||
input.onPhase?.(
|
||||
"post_restore_validation",
|
||||
@@ -1729,6 +1832,13 @@ export function formatWorktreeSeedFailureDiagnostic(
|
||||
if (phase === "restore" && /Cannot seed target embedded PostgreSQL.+already running/i.test(message)) {
|
||||
return "Target embedded PostgreSQL is owned by a running worktree service. Stop that service and retry the seed.";
|
||||
}
|
||||
if (
|
||||
/No auth user has a non-empty credential account, instance-admin role, and active company membership/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return "Seed validation could not find a credential-backed instance administrator with an active company membership. Authenticated instances must create or sign in an administrator before seeding.";
|
||||
}
|
||||
return `Seed failed during ${phase}.`;
|
||||
}
|
||||
|
||||
@@ -1841,6 +1951,7 @@ export function markWorktreeSeedPending(input: {
|
||||
targetInstanceId?: string;
|
||||
seedMode?: WorktreeSeedMode;
|
||||
now?: Date;
|
||||
diagnosticMessage?: string;
|
||||
}): void {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(input.configPath);
|
||||
const at = (input.now ?? new Date()).toISOString();
|
||||
@@ -1859,7 +1970,14 @@ export function markWorktreeSeedPending(input: {
|
||||
attemptId: randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
diagnostics: [{
|
||||
phase: "pending",
|
||||
status: "succeeded",
|
||||
at,
|
||||
...(input.diagnosticMessage
|
||||
? { message: input.diagnosticMessage.slice(0, WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT) }
|
||||
: {}),
|
||||
}],
|
||||
});
|
||||
// New manifests are authoritative. Legacy files are removed so no caller can
|
||||
// mistake a stale binary marker for current verified seed state.
|
||||
@@ -2135,11 +2253,14 @@ async function runVerifiedWorktreeSeed(input: {
|
||||
|
||||
export async function ensureWorktreeSeeded(
|
||||
opts: WorktreeEnsureSeededOptions = {},
|
||||
dependencies: { seedDatabase?: SeedWorktreeDatabase } = {},
|
||||
dependencies: {
|
||||
seedDatabase?: SeedWorktreeDatabase;
|
||||
inspectLegacyDatabase?: typeof inspectLegacyWorktreeDatabase;
|
||||
} = {},
|
||||
): Promise<EnsureWorktreeSeededResult> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const markers = resolveWorktreeSeedMarkerPaths(configPath);
|
||||
let initialManifest = readWorktreeSeedManifest(configPath);
|
||||
const initialManifest = readWorktreeSeedManifest(configPath);
|
||||
if (initialManifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
@@ -2149,14 +2270,6 @@ export async function ensureWorktreeSeeded(
|
||||
const legacyPending = !initialManifest && existsSync(markers.pending)
|
||||
? readLegacyWorktreeSeedPendingMarker(markers.pending)
|
||||
: null;
|
||||
if (!initialManifest && !legacyPending) {
|
||||
if (existsSync(markers.lock)) {
|
||||
const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
await releaseExistingLock();
|
||||
}
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
|
||||
const hasExplicitSource = Boolean(opts.fromConfig || opts.fromDataDir || opts.fromInstance);
|
||||
const explicitSourceConfigPath = hasExplicitSource
|
||||
? resolveSourceConfigPath({
|
||||
@@ -2168,6 +2281,13 @@ export async function ensureWorktreeSeeded(
|
||||
const registeredBaseWorkspaceCwd = opts.registeredBaseWorkspaceCwd
|
||||
?? nonEmpty(process.env.PAPERCLIP_WORKSPACE_BASE_CWD)
|
||||
?? null;
|
||||
if (!initialManifest && !legacyPending && !hasExplicitSource && !registeredBaseWorkspaceCwd) {
|
||||
if (existsSync(markers.lock)) {
|
||||
const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
await releaseExistingLock();
|
||||
}
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
const registeredProjectWorkspaceId = opts.registeredProjectWorkspaceId
|
||||
?? nonEmpty(process.env.PAPERCLIP_PROJECT_WORKSPACE_ID)
|
||||
?? null;
|
||||
@@ -2183,26 +2303,21 @@ export async function ensureWorktreeSeeded(
|
||||
|
||||
const targetRoot = path.dirname(path.dirname(configPath));
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot });
|
||||
const resolveSeedSource = (manifest: WorktreeSeedManifest | null) => {
|
||||
const diagnosticSource = manifest?.source ?? (legacyPending
|
||||
? {
|
||||
configPath: legacyPending.sourceConfigPath,
|
||||
instanceId: resolveSeedInstanceId(legacyPending.sourceConfigPath),
|
||||
}
|
||||
: null);
|
||||
return resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
manifestSource: diagnosticSource,
|
||||
manifestTargetInstanceId: manifest?.targetInstanceId ?? targetPaths.instanceId,
|
||||
});
|
||||
};
|
||||
const registeredSeedSource = resolveRegisteredWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
});
|
||||
|
||||
// Fail before creating the seed lock or rewriting a legacy marker. The manifest
|
||||
// is agent-writable diagnostic evidence and can never select this source.
|
||||
let canonicalSource = resolveSeedSource(initialManifest);
|
||||
if (initialManifest && initialManifest.targetInstanceId !== registeredSeedSource.targetInstanceId) {
|
||||
throw new Error("Worktree seed manifest target instance does not match the registered target instance.");
|
||||
}
|
||||
|
||||
// Resolve all authority-bearing paths before creating the lock. The manifest is
|
||||
// agent-writable diagnostic evidence and never selects the source. A stale source
|
||||
// diagnostic is replaced under the lock from this server/operator registration.
|
||||
let canonicalSource = registeredSeedSource;
|
||||
mkdirSync(path.dirname(markers.lock), { recursive: true });
|
||||
const releaseLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
try {
|
||||
@@ -2213,9 +2328,6 @@ export async function ensureWorktreeSeeded(
|
||||
if (manifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
if (!manifest && existsSync(markers.complete)) {
|
||||
return { seeded: false, reason: "complete_marker" };
|
||||
}
|
||||
if (!manifest && existsSync(markers.pending)) {
|
||||
const currentLegacyPending = readLegacyWorktreeSeedPendingMarker(markers.pending);
|
||||
if (currentLegacyPending.sourceConfigPath !== legacyPending?.sourceConfigPath) {
|
||||
@@ -2223,18 +2335,71 @@ export async function ensureWorktreeSeeded(
|
||||
}
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: canonicalSource.configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath);
|
||||
}
|
||||
if (!manifest) {
|
||||
// Worktrees created before lazy seeding shipped were seeded eagerly and
|
||||
// have neither marker. Preserve that compatibility without re-cloning.
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
const legacyEvidence = await (
|
||||
dependencies.inspectLegacyDatabase ?? inspectLegacyWorktreeDatabase
|
||||
)(configPath);
|
||||
if (legacyEvidence) {
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "Validated existing legacy worktree database schema before adoption.",
|
||||
});
|
||||
startWorktreeSeedAttempt(configPath);
|
||||
updateWorktreeSeedManifest({
|
||||
configPath,
|
||||
phase: "complete",
|
||||
status: "succeeded",
|
||||
state: "verified",
|
||||
snapshotAt: new Date().toISOString(),
|
||||
migrationRevision: legacyEvidence.migrationRevision,
|
||||
message: "Adopted an existing legacy worktree database after validating its migration journal and core schema.",
|
||||
});
|
||||
return { seeded: false, reason: "legacy_database" };
|
||||
}
|
||||
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "No verified seed or compatible legacy database was found; provisioning is required.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath);
|
||||
if (!manifest) {
|
||||
throw new Error("Failed to create a pending worktree seed manifest.");
|
||||
}
|
||||
}
|
||||
canonicalSource = resolveSeedSource(manifest);
|
||||
if (
|
||||
manifest.source.configPath !== registeredSeedSource.configPath
|
||||
|| manifest.source.instanceId !== registeredSeedSource.instanceId
|
||||
) {
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: manifest.targetInstanceId,
|
||||
seedMode: manifest.seedMode,
|
||||
diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath)!;
|
||||
}
|
||||
canonicalSource = resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
manifestSource: manifest.source,
|
||||
manifestTargetInstanceId: manifest.targetInstanceId,
|
||||
});
|
||||
const sourceConfigPath = canonicalSource.configPath;
|
||||
|
||||
const sourceConfig = readConfig(sourceConfigPath);
|
||||
@@ -2313,22 +2478,48 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(
|
||||
preferredDbPort,
|
||||
new Set([...claimedPorts.databasePorts, serverPort]),
|
||||
);
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
const { serverPort, databasePort, targetConfig } = await withWorktreePortRegistryLock(
|
||||
paths.homeDir,
|
||||
async () => {
|
||||
const registeredConfigPaths = readWorktreePortRegistry(paths.homeDir);
|
||||
const claimedPorts = collectClaimedWorktreePorts(
|
||||
paths.homeDir,
|
||||
paths.instanceId,
|
||||
paths.cwd,
|
||||
registeredConfigPaths,
|
||||
);
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const selectedServerPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const selectedDatabasePort = await findAvailablePort(
|
||||
preferredDbPort,
|
||||
new Set([...claimedPorts.databasePorts, selectedServerPort]),
|
||||
);
|
||||
const selectedConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort: selectedServerPort,
|
||||
databasePort: selectedDatabasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
try {
|
||||
writeConfig(selectedConfig, paths.configPath);
|
||||
writeWorktreePortRegistry(paths.homeDir, [
|
||||
...registeredConfigPaths,
|
||||
paths.configPath,
|
||||
]);
|
||||
} catch (error) {
|
||||
rmSync(paths.configPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
serverPort: selectedServerPort,
|
||||
databasePort: selectedDatabasePort,
|
||||
targetConfig: selectedConfig,
|
||||
};
|
||||
},
|
||||
);
|
||||
markWorktreeSeedPending({
|
||||
configPath: paths.configPath,
|
||||
sourceConfigPath,
|
||||
@@ -2436,21 +2627,17 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree ensure-seeded ")));
|
||||
|
||||
const markers = resolveWorktreeSeedMarkerPaths(resolveConfigPath(opts.config));
|
||||
if (existsSync(markers.complete) || !existsSync(markers.pending)) {
|
||||
const result = await ensureWorktreeSeeded(opts);
|
||||
const reason = result.reason === "complete_marker"
|
||||
? "Seed-complete marker already present."
|
||||
: "No seed-pending marker found; treating this legacy worktree as already seeded.";
|
||||
p.outro(pc.green(reason));
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start("Seeding isolated worktree database from source instance (minimal)...");
|
||||
spinner.start("Checking isolated worktree database seed state...");
|
||||
try {
|
||||
const result = await ensureWorktreeSeeded(opts);
|
||||
spinner.stop("Seeded isolated worktree database (minimal).");
|
||||
if (result.seeded) {
|
||||
spinner.stop("Seeded isolated worktree database (minimal).");
|
||||
} else if (result.reason === "legacy_database") {
|
||||
spinner.stop("Validated and adopted an existing legacy worktree database.");
|
||||
} else {
|
||||
spinner.stop("Worktree database already has a verified seed manifest.");
|
||||
}
|
||||
if (result.details) {
|
||||
p.log.message(pc.dim(`Seed snapshot: ${result.details.backupSummary}`));
|
||||
p.log.message(
|
||||
|
||||
Reference in new issue
Block a user