UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

1,008 lines (928 loc) • 34.1 kB
/** * KXM External Side-Effect Idempotency (Slice C) * Ensures deterministic branching, preflight CAS checks, and immutable receipts for external mutations. */ import { DatabaseSync } from "./sqlite.ts"; import { createHash } from "node:crypto"; import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, lstatSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { openDatabase } from "./database.ts"; import { spawnSync } from "node:child_process"; export const EXTERNAL_EFFECT_SCHEMA = "kxm.external-effect-receipt.v1" as const; export const DEFAULT_LEASE_TIMEOUT_MS = 300_000; // 300s (5 minutes) per Decision Q6 export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; // 30s per Decision Q6 export type ExternalActionKind = | "git-branch" | "git-commit" | "git-push" | "pr-create" | "tracker-issue" | "webhook"; export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted" | "uncertain"; export interface ExternalEffectReceipt { schema: typeof EXTERNAL_EFFECT_SCHEMA; effectKey: string; runId: string; stepId: string; attemptId: string; actionKind: ExternalActionKind; targetRef: string; status: ExternalEffectStatus; payloadHash: string; receiptPayload: Record<string, unknown>; executedAt: string; lastHeartbeatAt?: string | undefined; completedAt?: string | undefined; /** The hub lease this effect executes under, for shared kinds only. The * ledger's own CAS is per run/step; this pair is what fences the effect * against a writer on another box. */ leaseResource?: string | undefined; fencingToken?: number | undefined; } export interface RunBranchOptions { description?: string | undefined; workflowId?: string | undefined; issueKey?: string | undefined; format?: "prefix-run" | "run-suffix" | undefined; } export function slugifyBranchPart(text: string, maxLength: number = 40): string { const cleaned = text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (cleaned.length <= maxLength) return cleaned; return cleaned.slice(0, maxLength).replace(/-+$/, ""); } export function deterministicRunBranch( runId: string, descriptionOrOptions?: string | RunBranchOptions, ): string { const cleanId = runId.replace(/^run_/, ""); if (!descriptionOrOptions) { return `kxm/run-${cleanId}`; } const options: RunBranchOptions = typeof descriptionOrOptions === "string" ? { description: descriptionOrOptions } : descriptionOrOptions; const parts: string[] = []; if (options.workflowId) { const wf = slugifyBranchPart(options.workflowId, 25); if (wf) parts.push(wf); } if (options.issueKey) { const issue = slugifyBranchPart(options.issueKey, 20); if (issue && !parts.some((p) => p.includes(issue))) parts.push(issue); } if (options.description) { const desc = slugifyBranchPart(options.description, 35); if (desc && !parts.some((p) => p.includes(desc))) parts.push(desc); } const slug = parts.join("-").slice(0, 50).replace(/-+$/, ""); if (!slug) { return `kxm/run-${cleanId}`; } if (options.format === "prefix-run") { return `kxm/${slug}-run-${cleanId}`; } return `kxm/run-${cleanId}-${slug}`; } export function computeEffectKey( runId: string, stepId: string, actionKind: ExternalActionKind, targetRef: string, ): string { const raw = `${runId}:${stepId}:${actionKind}:${targetRef}`; return `eff_${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`; } // v1 → v2 adds the hub lease identity a shared effect executes under. There is no // migration lane here either: an older ledger file is refused at open, not reshaped. const EXTERNAL_EFFECTS_SCHEMA_VERSION = 2; const EXTERNAL_EFFECTS_DDL = ` CREATE TABLE IF NOT EXISTS external_effects ( effect_key TEXT PRIMARY KEY, run_id TEXT NOT NULL, step_id TEXT NOT NULL, attempt_id TEXT NOT NULL, action_kind TEXT NOT NULL, target_ref TEXT NOT NULL, status TEXT NOT NULL, payload_hash TEXT NOT NULL, receipt_payload TEXT NOT NULL, executed_at TEXT NOT NULL, last_heartbeat_at TEXT, completed_at TEXT, lease_resource TEXT, fencing_token INTEGER ); CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id); `; const EXTERNAL_EFFECTS_SHAPE = { external_effects: [ "effect_key", "run_id", "step_id", "attempt_id", "action_kind", "target_ref", "status", "payload_hash", "receipt_payload", "executed_at", "last_heartbeat_at", "completed_at", "lease_resource", "fencing_token", ], } as const; interface EffectRow { effect_key: string; run_id: string; step_id: string; attempt_id: string; action_kind: ExternalActionKind; target_ref: string; status: ExternalEffectStatus; payload_hash: string; receipt_payload: string; executed_at: string; last_heartbeat_at: string | null; completed_at: string | null; lease_resource: string | null; fencing_token: number | null; } function receiptFromRow(row: EffectRow): ExternalEffectReceipt { return { schema: EXTERNAL_EFFECT_SCHEMA, effectKey: row.effect_key, runId: row.run_id, stepId: row.step_id, attemptId: row.attempt_id, actionKind: row.action_kind, targetRef: row.target_ref, status: row.status, payloadHash: row.payload_hash, receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>, executedAt: row.executed_at, lastHeartbeatAt: row.last_heartbeat_at ?? undefined, completedAt: row.completed_at ?? undefined, leaseResource: row.lease_resource ?? undefined, fencingToken: row.fencing_token ?? undefined, }; } export class ExternalEffectsLedger { private db: DatabaseSync; constructor(dbPath: string = ":memory:") { if (dbPath !== ":memory:") { mkdirSync(dirname(dbPath), { recursive: true }); } // Goes through the shared opener, not a bare `new DatabaseSync`: that is what gives this // store the same contract as every other one — path checks, WAL, the `user_version` gate // that refuses an older stamp outright, and the column-shape check that turns a missing // column into `runtime_schema_shape_invalid` here instead of a bare SQL error on first use. this.db = openDatabase(dbPath, "external effects ledger", { schema: EXTERNAL_EFFECTS_DDL, version: EXTERNAL_EFFECTS_SCHEMA_VERSION, tables: EXTERNAL_EFFECTS_SHAPE, }); } /** * Preflight Check-And-Set (CAS): * Guarantees that an external mutation is only initiated if not already committed or currently in-flight. * Auto-reclaims stale leases after timeoutMs (default 300s per Decision Q6). */ claimEffect(input: { runId: string; stepId: string; attemptId: string; actionKind: ExternalActionKind; targetRef: string; payload?: Record<string, unknown> | undefined; timeoutMs?: number | undefined; /** The hub lease this effect runs under. Shared kinds carry it; unique * namespaces (`git-branch`, `git-commit`) leave it unset. */ leaseResource?: string | undefined; fencingToken?: number | undefined; }): { ok: true; effectKey: string } | { ok: false; error: string; existing?: ExternalEffectReceipt } { const effectKey = computeEffectKey(input.runId, input.stepId, input.actionKind, input.targetRef); const now = new Date().toISOString(); const payloadStr = JSON.stringify(input.payload ?? {}); const payloadHash = createHash("sha256").update(payloadStr).digest("hex"); const timeout = input.timeoutMs ?? DEFAULT_LEASE_TIMEOUT_MS; const existing = this.getReceipt(effectKey); if (existing) { if (existing.status === "uncertain") { return { ok: false, error: `effect_uncertain: ${input.actionKind} on ${input.targetRef} is parked as uncertain after a fencing conflict and requires explicit recovery`, existing, }; } if (existing.status === "committed") { return { ok: false, error: `effect_already_committed: ${input.actionKind} on ${input.targetRef} was already committed`, existing, }; } if (existing.status === "in-flight") { const lastActivity = existing.lastHeartbeatAt ?? existing.executedAt; const elapsed = Date.now() - Date.parse(lastActivity); if (elapsed < timeout) { return { ok: false, error: `effect_in_flight: ${input.actionKind} on ${input.targetRef} is currently executing`, existing, }; } // If timed out, allow reclaim by updating status and timestamps } } const stmt = this.db.prepare(` INSERT INTO external_effects ( effect_key, run_id, step_id, attempt_id, action_kind, target_ref, status, payload_hash, receipt_payload, executed_at, last_heartbeat_at, lease_resource, fencing_token ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(effect_key) DO UPDATE SET attempt_id = excluded.attempt_id, status = 'in-flight', executed_at = excluded.executed_at, last_heartbeat_at = excluded.last_heartbeat_at, payload_hash = excluded.payload_hash, receipt_payload = excluded.receipt_payload, lease_resource = excluded.lease_resource, fencing_token = excluded.fencing_token `); stmt.run( effectKey, input.runId, input.stepId, input.attemptId, input.actionKind, input.targetRef, "in-flight", payloadHash, payloadStr, now, now, input.leaseResource ?? null, input.fencingToken ?? null, ); return { ok: true, effectKey }; } /** * Refreshes the lease heartbeat for an in-flight effect (Decision Q6). * Workers invoke this periodically (default 30s) while performing side-effects. */ heartbeatEffect(effectKey: string): { ok: true; lastHeartbeatAt: string } | { ok: false; error: string } { const existing = this.getReceipt(effectKey); if (!existing) { return { ok: false, error: `effect_not_found: effect ${effectKey} does not exist` }; } if (existing.status !== "in-flight") { return { ok: false, error: `effect_not_in_flight: cannot heartbeat effect in status '${existing.status}'`, }; } const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE external_effects SET last_heartbeat_at = ? WHERE effect_key = ? AND status = 'in-flight' `); stmt.run(now, effectKey); return { ok: true, lastHeartbeatAt: now }; } /** Park an effect as uncertain: a fencing conflict means the outside world * may have been touched, and only explicit recovery can unblock it. */ markUncertain(effectKey: string): void { this.db.prepare("UPDATE external_effects SET status = 'uncertain' WHERE effect_key = ? AND status = 'in-flight'").run(effectKey); } commitEffect( effectKey: string, receiptPayload: Record<string, unknown>, ): void { const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE external_effects SET status = 'committed', receipt_payload = ?, completed_at = ? WHERE effect_key = ? `); stmt.run(JSON.stringify(receiptPayload), now, effectKey); } abortEffect(effectKey: string, reason: string): void { const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE external_effects SET status = 'aborted', receipt_payload = ?, completed_at = ? WHERE effect_key = ? `); stmt.run(JSON.stringify({ error: reason }), now, effectKey); } getReceipt(effectKey: string): ExternalEffectReceipt | undefined { const stmt = this.db.prepare(` SELECT * FROM external_effects WHERE effect_key = ? `); const row = stmt.get(effectKey) as EffectRow | undefined; if (!row) return undefined; return receiptFromRow(row); } listRunEffects(runId: string): ExternalEffectReceipt[] { const stmt = this.db.prepare(` SELECT * FROM external_effects WHERE run_id = ? ORDER BY executed_at ASC `); const rows = stmt.all(runId) as unknown as EffectRow[]; return rows.map(receiptFromRow); } close(): void { this.db.close(); } } // --------------------------------------------------------------------------- // Shared effects under a fenced hub lease (P3) // --------------------------------------------------------------------------- /** * The lease surface a shared effect needs. Declared structurally so this module * stays a leaf: `HubClient` satisfies it as written, and a test can stand in a * stub without a hub. */ export interface EffectLeaseGateway { acquireLease(resource: string, ttlMs?: number): Promise<{ lease: EffectLease; renewed: boolean }>; renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: EffectLease }>; releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean }>; } export interface EffectLease { resource: string; fencingToken: number; expiresAt: string; holderAgentId?: string | undefined; holderAgentName?: string | undefined; } export type SharedEffectRefusalCode = /** No lease could be obtained: no gateway was bound, or the hub was unreachable. */ | "effect_lease_unavailable" /** Another agent holds the resource right now. */ | "effect_lease_held" /** The hub has moved past the token this effect holds. */ | "effect_lease_superseded" | "effect_already_committed" | "effect_in_flight" /** The effect is parked as uncertain after a fencing conflict. */ | "effect_uncertain" | "effect_not_in_flight" | "effect_not_found"; /** The engine's attempt state for an effect whose outcome cannot be proven. */ export type EffectAttemptState = "blocked_uncertain"; export interface SharedEffectRefusal { ok: false; code: SharedEffectRefusalCode; error: string; /** Present when the effect may already have touched the outside world. The * attempt parks here and nothing retries it. */ attemptState?: EffectAttemptState | undefined; existing?: ExternalEffectReceipt | undefined; } export interface SharedEffectClaim { ok: true; effectKey: string; leaseResource?: string | undefined; fencingToken?: number | undefined; } /** Kinds whose target is shared with every other box: two runs naming the same * `targetRef` mean the same real thing, so exactly one may execute. */ export const SHARED_EFFECT_KINDS: readonly ExternalActionKind[] = Object.freeze([ "git-push", "pr-create", "tracker-issue", "webhook", ]); /** Does `targetRef` name a branch this run owns? A run branch is a unique * namespace — `deterministicRunBranch` derives it from the run id in all three * of its formats — so pushing it contends with nobody. */ export function isRunBranchRef(runId: string, targetRef: string): boolean { const cleanId = runId.replace(/^run_/, ""); if (!cleanId) return false; const branch = targetRef.replace(/^refs\/heads\//, ""); if (!branch.startsWith("kxm/")) return false; return branch === `kxm/run-${cleanId}` || branch.startsWith(`kxm/run-${cleanId}-`) || branch.endsWith(`-run-${cleanId}`); } /** * Whether an effect must hold a hub lease before it executes. * * `git-branch` and `git-commit` write a namespace this run already owns, so * they stay lease-free. A `git-push` to this run's own branch is the same case; * a push to any other ref is shared and is fenced. */ export function effectRequiresHubLease( actionKind: ExternalActionKind, targetRef: string, runId: string, ): boolean { if (!SHARED_EFFECT_KINDS.includes(actionKind)) return false; if (actionKind === "git-push") return !isRunBranchRef(runId, targetRef); return true; } function errorCodeOf(error: unknown): string | undefined { const code = (error as { code?: unknown } | undefined)?.code; return typeof code === "string" ? code : undefined; } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** A refusal the hub stated about the lease itself, as opposed to not reaching * the hub at all. Any of these means the token is gone for good. */ function isLeaseLost(code: string | undefined): boolean { return code === "lease_superseded" || code === "lease_expired" || code === "lease_not_found"; } /** * Claim an external effect, taking its hub lease first when the kind is shared. * * The lease comes before the ledger row on purpose: the receipt records the * token it will have to present at commit, and a claim that cannot be fenced * never becomes a claim at all. A shared effect with an unreachable hub is * refused with `effect_lease_unavailable` and executes nothing — running it * unfenced is exactly the two-writer failure the lease exists to prevent. */ export async function claimSharedEffect(input: { ledger: ExternalEffectsLedger; lease?: EffectLeaseGateway | undefined; runId: string; stepId: string; attemptId: string; actionKind: ExternalActionKind; targetRef: string; payload?: Record<string, unknown> | undefined; timeoutMs?: number | undefined; /** Lease TTL. Defaults to the Q6 lease timeout so the existing heartbeat * interval renews it well inside its deadline. */ leaseTtlMs?: number | undefined; }): Promise<SharedEffectClaim | SharedEffectRefusal> { const needsLease = effectRequiresHubLease(input.actionKind, input.targetRef, input.runId); let lease: EffectLease | undefined; let leaseRenewed = false; if (needsLease) { if (!input.lease) { return { ok: false, code: "effect_lease_unavailable", error: `effect_lease_unavailable: ${input.actionKind} on ${input.targetRef} is shared and no hub lease is bound`, }; } try { const acquired = await input.lease.acquireLease(input.targetRef, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS); lease = acquired.lease; leaseRenewed = acquired.renewed === true; } catch (error) { const code = errorCodeOf(error); if (code === "lease_held") { return { ok: false, code: "effect_lease_held", error: `effect_lease_held: ${input.targetRef} is leased by another agent: ${errorText(error)}`, }; } return { ok: false, code: "effect_lease_unavailable", error: `effect_lease_unavailable: could not reach the bound hub for ${input.targetRef}: ${errorText(error)}`, }; } } const claimed = input.ledger.claimEffect({ runId: input.runId, stepId: input.stepId, attemptId: input.attemptId, actionKind: input.actionKind, targetRef: input.targetRef, payload: input.payload, timeoutMs: input.timeoutMs, ...(lease ? { leaseResource: lease.resource, fencingToken: lease.fencingToken } : {}), }); if (!claimed.ok) { // Only hand back a lease this caller newly acquired. A renewal of an // existing holder's lease must survive a claim refusal: the original // effect is still in-flight and still needs the fence. if (lease && input.lease && !leaseRenewed) { await releaseQuietly(input.lease, input.targetRef, lease.fencingToken); } return { ok: false, code: claimed.error.startsWith("effect_already_committed") ? "effect_already_committed" : claimed.error.startsWith("effect_uncertain") ? "effect_uncertain" : "effect_in_flight", error: claimed.error, existing: claimed.existing, }; } return { ok: true, effectKey: claimed.effectKey, ...(lease ? { leaseResource: lease.resource, fencingToken: lease.fencingToken } : {}), }; } /** * The Q6 heartbeat for a shared effect: renew the hub lease under the same * token, then refresh the ledger's own lease timestamp. * * A hub that has moved past the token parks the attempt — the effect may * already have touched the outside world, and the resource now belongs to * someone else. */ export async function heartbeatSharedEffect(input: { ledger: ExternalEffectsLedger; lease?: EffectLeaseGateway | undefined; effectKey: string; leaseTtlMs?: number | undefined; }): Promise<{ ok: true; lastHeartbeatAt: string; leaseExpiresAt?: string } | SharedEffectRefusal> { const receipt = input.ledger.getReceipt(input.effectKey); if (!receipt) { return { ok: false, code: "effect_not_found", error: `effect_not_found: effect ${input.effectKey} does not exist` }; } let leaseExpiresAt: string | undefined; if (receipt.leaseResource !== undefined && receipt.fencingToken !== undefined) { if (!input.lease) { return { ok: false, code: "effect_lease_unavailable", error: `effect_lease_unavailable: ${receipt.targetRef} holds a hub lease but no gateway is bound`, }; } try { const renewed = await input.lease.renewLease( receipt.targetRef, receipt.fencingToken, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS, ); leaseExpiresAt = renewed.lease.expiresAt; } catch (error) { const code = errorCodeOf(error); if (isLeaseLost(code)) { return { ok: false, code: "effect_lease_superseded", error: `effect_lease_superseded: the hub no longer recognises token ${receipt.fencingToken} on ${receipt.targetRef}: ${errorText(error)}`, attemptState: "blocked_uncertain", existing: receipt, }; } return { ok: false, code: "effect_lease_unavailable", error: `effect_lease_unavailable: could not renew the lease on ${receipt.targetRef}: ${errorText(error)}`, existing: receipt, }; } } const beat = input.ledger.heartbeatEffect(input.effectKey); if (!beat.ok) { // The row exists — `getReceipt` just read it — so the only remaining refusal // is a status that no longer accepts a heartbeat. return { ok: false, code: "effect_not_in_flight", error: beat.error, existing: receipt }; } return { ok: true, lastHeartbeatAt: beat.lastHeartbeatAt, ...(leaseExpiresAt ? { leaseExpiresAt } : {}) }; } /** * Commit a shared effect, but only while the hub still recognises its token. * * The token is re-presented before the receipt is written. If the hub has * superseded it — or cannot be reached to say either way — the effect is left * `in-flight` and the attempt parks `blocked_uncertain`. Nothing retries: the * write may or may not have landed, and re-running it is how a second writer * lands the same change twice. */ export async function commitSharedEffect(input: { ledger: ExternalEffectsLedger; lease?: EffectLeaseGateway | undefined; effectKey: string; receiptPayload: Record<string, unknown>; leaseTtlMs?: number | undefined; }): Promise<{ ok: true; receipt: ExternalEffectReceipt } | SharedEffectRefusal> { const receipt = input.ledger.getReceipt(input.effectKey); if (!receipt) { return { ok: false, code: "effect_not_found", error: `effect_not_found: effect ${input.effectKey} does not exist` }; } const fenced = receipt.leaseResource !== undefined && receipt.fencingToken !== undefined; if (fenced) { if (!input.lease) { return { ok: false, code: "effect_lease_unavailable", error: `effect_lease_unavailable: ${receipt.targetRef} holds a hub lease but no gateway is bound`, attemptState: "blocked_uncertain", existing: receipt, }; } try { await input.lease.renewLease(receipt.targetRef, receipt.fencingToken!, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS); } catch (error) { const code = errorCodeOf(error); if (isLeaseLost(code)) { input.ledger.markUncertain(input.effectKey); } return { ok: false, code: isLeaseLost(code) ? "effect_lease_superseded" : "effect_lease_unavailable", error: isLeaseLost(code) ? `effect_lease_superseded: token ${receipt.fencingToken} on ${receipt.targetRef} was superseded before commit: ${errorText(error)}` : `effect_lease_unavailable: the lease on ${receipt.targetRef} could not be confirmed before commit: ${errorText(error)}`, attemptState: "blocked_uncertain", existing: input.ledger.getReceipt(input.effectKey), }; } } input.ledger.commitEffect(input.effectKey, input.receiptPayload); if (fenced && input.lease) { await releaseQuietly(input.lease, receipt.targetRef, receipt.fencingToken!); } return { ok: true, receipt: input.ledger.getReceipt(input.effectKey)! }; } /** Hand the resource back. `resource` is always the name the caller asked for, * never the hub's project-scoped form — the hub applies that prefix itself, and * passing it back would name a second resource. A failed release is not an * error the caller can act on: the lease falls to its deadline on the hub * clock. */ async function releaseQuietly(gateway: EffectLeaseGateway, resource: string, fencingToken: number): Promise<void> { try { await gateway.releaseLease(resource, fencingToken); } catch { // Deliberately swallowed: the TTL frees it. } } export interface WorktreeLock { lockPath: string; release: () => void; } /** * Resolves the lockfile location for concurrent git worktree modifications. * By default targets .git/kxm-worktree.lock, or if in a linked worktree, targets the main git directory. */ export function resolveWorktreeLockPath(repoRoot: string): string { const gitPath = join(repoRoot, ".git"); if (existsSync(gitPath)) { try { const stat = lstatSync(gitPath); if (stat.isDirectory()) { return join(gitPath, "kxm-worktree.lock"); } if (stat.isFile()) { const content = readFileSync(gitPath, "utf8").trim(); const match = content.match(/^gitdir:\s*(.+)$/i); if (match && match[1]) { const resolvedGitDir = resolve(repoRoot, match[1]); return join(resolvedGitDir, "kxm-worktree.lock"); } } } catch { // Fall back } } return join(repoRoot, ".kxm-worktree.lock"); } /** * Acquires an exclusive file-based lock for worktree mutations. * Supports auto-breaking stale locks if the holding process has died or timed out. */ export function acquireWorktreeLock( repoRoot: string, options?: { timeoutMs?: number | undefined; staleTimeoutMs?: number | undefined; pollIntervalMs?: number | undefined; }, ): { ok: true; lock: WorktreeLock } | { ok: false; error: string } { const lockPath = resolveWorktreeLockPath(repoRoot); const timeoutMs = options?.timeoutMs ?? 5_000; const staleTimeoutMs = options?.staleTimeoutMs ?? 30_000; const pollIntervalMs = options?.pollIntervalMs ?? 50; const startTime = Date.now(); mkdirSync(dirname(lockPath), { recursive: true }); while (true) { try { const fd = openSync(lockPath, "wx"); const metadata = JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString(), repoRoot, }); writeFileSync(fd, metadata, "utf8"); closeSync(fd); return { ok: true, lock: { lockPath, release: () => { try { unlinkSync(lockPath); } catch { // Ignore if already unlinked } }, }, }; } catch (err: unknown) { const code = (err as { code?: string }).code; if (code !== "EEXIST") { return { ok: false, error: `lock_io_error: ${(err as Error).message}` }; } // Existing lock found - check for staleness try { const content = readFileSync(lockPath, "utf8"); const parsed = JSON.parse(content) as { pid?: number; acquiredAt?: string }; let isStale = false; if (parsed.acquiredAt) { const age = Date.now() - Date.parse(parsed.acquiredAt); if (age > staleTimeoutMs) { isStale = true; } } if (parsed.pid && typeof parsed.pid === "number") { try { process.kill(parsed.pid, 0); } catch (e: unknown) { if ((e as { code?: string }).code === "ESRCH") { isStale = true; // Process dead } } } if (isStale) { try { unlinkSync(lockPath); continue; // Immediately retry lock } catch { // Already unlinked or contention } } } catch { // Corrupt lock file try { unlinkSync(lockPath); continue; } catch { // Ignore } } if (Date.now() - startTime >= timeoutMs) { return { ok: false, error: `lock_timeout: timed out after ${timeoutMs}ms waiting for worktree lock at ${lockPath}`, }; } if (pollIntervalMs > 0) { const waitBuf = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(waitBuf, 0, 0, pollIntervalMs); } } } } /** * Runs a function inside an exclusive worktree lock. */ export async function withWorktreeLock<T>( repoRoot: string, fn: () => T | Promise<T>, options?: { timeoutMs?: number | undefined; staleTimeoutMs?: number | undefined; pollIntervalMs?: number | undefined; }, ): Promise<T> { const res = acquireWorktreeLock(repoRoot, options); if (!res.ok) { throw new Error(`worktree_lock_failed: ${res.error}`); } try { return await fn(); } finally { res.lock.release(); } } export interface BranchCleanupOptions { removeWorktree?: boolean | undefined; deleteRemote?: boolean | undefined; remoteName?: string | undefined; execFn?: ((cmd: string, args: string[]) => { status: number; stdout: string; stderr: string }) | undefined; } export interface BranchCleanupResult { ok: boolean; branchName: string; deletedLocalBranch: boolean; deletedRemoteBranch: boolean; removedWorktreePath?: string | undefined; error?: string | undefined; } /** * Deterministic Branch Cleanup (Decision Q7). * Removes associated git worktrees and deletes the local (and optionally remote) run branch. */ export function cleanupMergedRunBranch( repoRoot: string, branchName: string, options?: BranchCleanupOptions, ): BranchCleanupResult { const runner = options?.execFn ?? ((cmd: string, args: string[]) => { const res = spawnSync(cmd, args, { cwd: repoRoot, encoding: "utf8", windowsHide: true, env: Object.fromEntries( Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")), ), }); return { status: res.status ?? 1, stdout: res.stdout || "", stderr: res.stderr || "", }; }); const shouldRemoveWorktree = options?.removeWorktree ?? true; let removedWorktreePath: string | undefined; let deletedLocalBranch = false; let deletedRemoteBranch = false; // 1. Remove worktree if one is attached to this branch if (shouldRemoveWorktree) { try { const wtList = runner("git", ["worktree", "list", "--porcelain"]); if (wtList.status === 0) { const blocks = wtList.stdout.split(/\n\n+/); for (const block of blocks) { const lines = block.trim().split("\n"); let wtPath: string | undefined; let wtBranch: string | undefined; for (const line of lines) { if (line.startsWith("worktree ")) { wtPath = line.substring("worktree ".length).trim(); } else if (line.startsWith("branch ")) { wtBranch = line.substring("branch ".length).trim(); } } if (wtPath && wtBranch && (wtBranch === `refs/heads/${branchName}` || wtBranch === branchName)) { const rmRes = runner("git", ["worktree", "remove", "--force", wtPath]); if (rmRes.status === 0) { removedWorktreePath = wtPath; } else { return { ok: false, branchName, deletedLocalBranch: false, deletedRemoteBranch: false, error: `failed_to_remove_worktree: ${rmRes.stderr.trim() || rmRes.stdout.trim()}`, }; } } } } } catch (err: unknown) { return { ok: false, branchName, deletedLocalBranch: false, deletedRemoteBranch: false, error: `worktree_inspection_failed: ${(err as Error).message}`, }; } } // 2. Delete local branch if it exists const checkBranch = runner("git", ["rev-parse", "--verify", `refs/heads/${branchName}`]); if (checkBranch.status === 0) { const delRes = runner("git", ["branch", "-D", branchName]); if (delRes.status === 0) { deletedLocalBranch = true; } else { return { ok: false, branchName, deletedLocalBranch: false, deletedRemoteBranch: false, ...(removedWorktreePath ? { removedWorktreePath } : {}), error: `failed_to_delete_local_branch: ${delRes.stderr.trim() || delRes.stdout.trim()}`, }; } } // 3. Delete remote branch if requested if (options?.deleteRemote) { const remote = options.remoteName || "origin"; const remoteDelRes = runner("git", ["push", remote, "--delete", branchName]); if (remoteDelRes.status === 0) { deletedRemoteBranch = true; } else { return { ok: false, branchName, deletedLocalBranch, deletedRemoteBranch: false, ...(removedWorktreePath ? { removedWorktreePath } : {}), error: `failed_to_delete_remote_branch: ${remoteDelRes.stderr.trim() || remoteDelRes.stdout.trim()}`, }; } } return { ok: true, branchName, deletedLocalBranch, deletedRemoteBranch, ...(removedWorktreePath ? { removedWorktreePath } : {}), }; }