diff --git a/scripts/run-tests-deterministic.ts b/scripts/run-tests-deterministic.ts index d7e0bf1d..030d0a92 100644 --- a/scripts/run-tests-deterministic.ts +++ b/scripts/run-tests-deterministic.ts @@ -9,6 +9,7 @@ const isolatedTestPaths = [ "tests/runtime-projects.test.ts", "tests/projects-registry.test.ts", "tests/project-run-command.test.ts", + "tests/project-exec-command.test.ts", "tests/cli-offline-optionality-matrix.test.ts", "tests/session-env-command.test.ts", ] as const; @@ -84,6 +85,9 @@ function testLabel(opts: { readonly testPath: string }): string { if (opts.testPath === "tests/project-run-command.test.ts") { return "project run command"; } + if (opts.testPath === "tests/project-exec-command.test.ts") { + return "project exec command"; + } if (opts.testPath === "tests/cli-offline-optionality-matrix.test.ts") { return "offline optionality matrix"; } diff --git a/src/commands/project.ts b/src/commands/project.ts index 3c0a9131..3d66c149 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -85,6 +85,7 @@ import { resolveHackEnv, upsertDotEnvValue } from "../lib/hack-env.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; import { appendLifecycleLogRecord, + type LifecycleStateEntry, readLifecycleState, removeLifecycleStateEntry, resolveLifecycleComposeProjectName, @@ -168,6 +169,7 @@ const CADDY_LABEL_PATTERN = /^(\s*)caddy:\s*(.*)$/; /** Regex to check if a string starts with a URL scheme (e.g., "http://", "https://"). */ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//; +const WHITESPACE_PATTERN = /\s+/; const optManual = defineOption({ name: "manual", @@ -1711,6 +1713,8 @@ async function startLifecycleProcess(opts: { readonly name: string; readonly windowName: string; readonly logPath: string; + readonly panePid?: number; + readonly processGroupId?: number; }> { const windowNameRaw = sanitizeBranchSlug(opts.process.name); const windowName = @@ -1751,6 +1755,15 @@ async function startLifecycleProcess(opts: { `Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}` ); } + const panePids = await readTmuxPanePids({ + sessionName: opts.sessionName, + windowName, + }); + const panePid = panePids[0]; + const processGroupId = + panePid !== undefined + ? await readProcessGroupIdForPid({ pid: panePid }) + : null; await appendLifecycleLogRecord({ projectDir: opts.projectDir, composeProject: opts.composeProject, @@ -1765,6 +1778,8 @@ async function startLifecycleProcess(opts: { name: opts.process.name, windowName, logPath, + ...(panePid !== undefined ? { panePid } : {}), + ...(processGroupId ? { processGroupId } : {}), }; } @@ -1818,6 +1833,13 @@ async function stopLifecycleProcesses(opts: { projectName: opts.projectName, branch: opts.branch, }); + const lifecycleEntries = await readLifecycleState({ + projectDir: opts.project.projectDir, + }); + const lifecycleEntry = + lifecycleEntries.find( + (entry) => entry.composeProject === opts.composeProject + ) ?? null; const backends = getMuxBackends(); for (const backend of backends.values()) { @@ -1828,6 +1850,12 @@ async function stopLifecycleProcesses(opts: { if (!sessions.some((s) => s.name === sessionName)) { continue; } + if (backend.name === "tmux") { + await interruptLifecycleTmuxProcesses({ + sessionName, + lifecycleEntry, + }); + } await backend.killSession({ name: sessionName }); } @@ -1837,6 +1865,232 @@ async function stopLifecycleProcesses(opts: { }); } +async function interruptLifecycleTmuxProcesses(opts: { + readonly sessionName: string; + readonly lifecycleEntry: LifecycleStateEntry | null; +}): Promise { + const processWindows = opts.lifecycleEntry?.processes ?? []; + if (processWindows.length === 0) { + return; + } + + for (const processInfo of processWindows) { + await exec( + [ + "tmux", + "send-keys", + "-t", + `${opts.sessionName}:${processInfo.windowName}`, + "C-c", + ], + { stdin: "ignore" } + ); + } + + await Bun.sleep(750); + await terminateLifecycleProcessGroups({ + processGroupIds: await resolveLifecycleProcessGroupIds({ + sessionName: opts.sessionName, + lifecycleEntry: opts.lifecycleEntry, + }), + }); +} + +type ProcessSnapshotRow = { + readonly pid: number; + readonly ppid: number; + readonly processGroupId: number; +}; + +async function readTmuxPanePids(opts: { + readonly sessionName: string; + readonly windowName: string; +}): Promise { + const result = await exec( + [ + "tmux", + "list-panes", + "-t", + `${opts.sessionName}:${opts.windowName}`, + "-F", + "#{pane_pid}", + ], + { stdin: "ignore" } + ); + if (result.exitCode !== 0) { + return []; + } + return result.stdout + .split("\n") + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((value) => Number.isInteger(value) && value > 0); +} + +async function readProcessGroupIdForPid(opts: { + readonly pid: number; +}): Promise { + const result = await exec(["ps", "-o", "pgid=", "-p", String(opts.pid)], { + stdin: "ignore", + }); + if (result.exitCode !== 0) { + return null; + } + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +async function readProcessSnapshot(): Promise { + const result = await exec(["ps", "-axo", "pid=,ppid=,pgid="], { + stdin: "ignore", + }); + if (result.exitCode !== 0) { + return []; + } + return parseProcessSnapshotOutput(result.stdout); +} + +export function parseProcessSnapshotOutput(text: string): ProcessSnapshotRow[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line) => { + const parts = line.split(WHITESPACE_PATTERN); + if (parts.length < 3) { + return []; + } + const pid = Number.parseInt(parts[0] ?? "", 10); + const ppid = Number.parseInt(parts[1] ?? "", 10); + const processGroupId = Number.parseInt(parts[2] ?? "", 10); + if ( + !( + Number.isInteger(pid) && + pid > 0 && + Number.isInteger(ppid) && + ppid >= 0 && + Number.isInteger(processGroupId) && + processGroupId > 0 + ) + ) { + return []; + } + return [{ pid, ppid, processGroupId }]; + }); +} + +export function collectDescendantProcessGroupIds(opts: { + readonly snapshot: readonly ProcessSnapshotRow[]; + readonly rootPids: readonly number[]; +}): number[] { + const processByParent = new Map(); + const groups = new Set(); + const queue = [...opts.rootPids]; + const visited = new Set(); + + for (const row of opts.snapshot) { + const siblings = processByParent.get(row.ppid) ?? []; + siblings.push(row); + processByParent.set(row.ppid, siblings); + } + + while (queue.length > 0) { + const pid = queue.shift(); + if (!(pid && pid > 0) || visited.has(pid)) { + continue; + } + visited.add(pid); + + const current = opts.snapshot.find((row) => row.pid === pid); + if (current) { + groups.add(current.processGroupId); + } + + for (const child of processByParent.get(pid) ?? []) { + groups.add(child.processGroupId); + if (!visited.has(child.pid)) { + queue.push(child.pid); + } + } + } + + return [...groups].sort((left, right) => left - right); +} + +export function resolveLifecycleProcessGroupIdsForTmuxState(opts: { + readonly lifecycleEntry: LifecycleStateEntry | null; + readonly panePidsByWindow: ReadonlyMap; + readonly snapshot: readonly ProcessSnapshotRow[]; +}): number[] { + const rootPids = new Set(); + + for (const processInfo of opts.lifecycleEntry?.processes ?? []) { + const currentPanePids = + opts.panePidsByWindow.get(processInfo.windowName) ?? []; + for (const panePid of currentPanePids) { + rootPids.add(panePid); + } + } + + return collectDescendantProcessGroupIds({ + snapshot: opts.snapshot, + rootPids: [...rootPids], + }); +} + +async function resolveLifecycleProcessGroupIds(opts: { + readonly sessionName: string; + readonly lifecycleEntry: LifecycleStateEntry | null; +}): Promise { + const panePidsByWindow = new Map(); + for (const processInfo of opts.lifecycleEntry?.processes ?? []) { + const panePids = await readTmuxPanePids({ + sessionName: opts.sessionName, + windowName: processInfo.windowName, + }); + panePidsByWindow.set(processInfo.windowName, panePids); + } + + return resolveLifecycleProcessGroupIdsForTmuxState({ + lifecycleEntry: opts.lifecycleEntry, + panePidsByWindow, + snapshot: await readProcessSnapshot(), + }); +} + +async function terminateLifecycleProcessGroups(opts: { + readonly processGroupIds: readonly number[]; +}): Promise { + const groups = [...new Set(opts.processGroupIds)].filter( + (processGroupId) => processGroupId > 1 + ); + if (groups.length === 0) { + return; + } + + for (const processGroupId of groups) { + try { + process.kill(-processGroupId, "SIGTERM"); + } catch { + // Ignore groups that already exited between snapshot and shutdown. + } + } + + await Bun.sleep(500); + + for (const processGroupId of groups) { + try { + process.kill(-processGroupId, 0); + } catch { + continue; + } + try { + process.kill(-processGroupId, "SIGKILL"); + } catch { + // Ignore groups that exited after the SIGTERM grace period. + } + } +} + function resolveLifecycleCommandServiceName(opts: { readonly command: ProjectLifecycleCommand; readonly index: number; @@ -1848,20 +2102,57 @@ function resolveLifecycleCommandServiceName(opts: { return `hook-${opts.index + 1}`; } -function wrapLifecyclePersistentCommand(opts: { +export function wrapLifecyclePersistentCommand(opts: { readonly command: string; readonly logPath: string; readonly serviceName: string; }): string { const logPath = shellSingleQuote(opts.logPath); const service = shellSingleQuote(opts.serviceName); + const command = shellSingleQuote(opts.command); return [ `HACK_LIFECYCLE_LOG=${logPath}`, `HACK_LIFECYCLE_SERVICE=${service}`, - `${opts.command} 2>&1 | while IFS= read -r line; do`, - " printf '%s\\n' \"$line\"", - ' printf \'%s\\t%s\\tstdout\\t%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HACK_LIFECYCLE_SERVICE" "$line" >> "$HACK_LIFECYCLE_LOG"', - "done", + `HACK_LIFECYCLE_COMMAND=${command}`, + `fifo="$(mktemp -u "\${TMPDIR:-/tmp}/hack-lifecycle.XXXXXX")"`, + 'mkfifo "$fifo"', + "cleanup_lifecycle() {", + " trap - EXIT INT TERM HUP", + ` if [ -n "\${cmd_pid:-}" ]; then`, + " if [ -x /bin/kill ]; then", + ' /bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || /bin/kill "$cmd_pid" 2>/dev/null || true', + " elif [ -x /usr/bin/kill ]; then", + ' /usr/bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || /usr/bin/kill "$cmd_pid" 2>/dev/null || true', + " else", + ' kill "$cmd_pid" 2>/dev/null || true', + " fi", + ' wait "$cmd_pid" 2>/dev/null || true', + " fi", + ` if [ -n "\${reader_pid:-}" ]; then`, + ' wait "$reader_pid" 2>/dev/null || true', + " fi", + ' rm -f "$fifo"', + "}", + 'trap "cleanup_lifecycle; exit 130" INT TERM HUP', + 'trap "cleanup_lifecycle" EXIT', + "( while IFS= read -r line; do", + ' printf "%s\\n" "$line"', + ' printf \'%s\\t%s\\tstdout\\t%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HACK_LIFECYCLE_SERVICE" "$line" >> "$HACK_LIFECYCLE_LOG"', + ' done < "$fifo" ) &', + "reader_pid=$!", + "if command -v python3 >/dev/null 2>&1; then", + ' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-lc", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &', + "else", + ' sh -lc "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &', + "fi", + "cmd_pid=$!", + 'wait "$cmd_pid"', + "cmd_status=$?", + 'cmd_pid=""', + 'wait "$reader_pid" 2>/dev/null || true', + 'reader_pid=""', + 'rm -f "$fifo"', + 'exit "$cmd_status"', ].join("\n"); } diff --git a/src/control-plane/extensions/tickets/sqlite-projection.ts b/src/control-plane/extensions/tickets/sqlite-projection.ts index 6e9ff6fa..bcbabfcd 100644 --- a/src/control-plane/extensions/tickets/sqlite-projection.ts +++ b/src/control-plane/extensions/tickets/sqlite-projection.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { mkdir, readdir } from "node:fs/promises"; +import { mkdir, readdir, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { TicketDocument } from "./documents.ts"; @@ -65,12 +65,12 @@ export function createTicketsSqliteProjection(opts: { const files = await Promise.all( entries.map(async (entry) => { - const text = await Bun.file(resolve(eventsDir, entry)) - .text() - .catch(() => ""); + const filePath = resolve(eventsDir, entry); + const metadata = await stat(filePath).catch(() => null); return { entry, - text, + size: metadata?.size ?? -1, + mtimeMs: metadata?.mtimeMs ?? -1, }; }) ); @@ -79,7 +79,8 @@ export function createTicketsSqliteProjection(opts: { value: stableStringify( files.map((file) => ({ entry: file.entry, - text: file.text, + size: file.size, + mtimeMs: file.mtimeMs, })) ), }); diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index 773888ef..bb4f26c1 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -603,7 +603,7 @@ export function createTicketsStore(opts: { } > => { try { - const root = await git.ensureCheckedOut(); + const root = await git.ensureCheckedOut({ refreshRemote: false }); const events = await readAllEventsFromRoot({ root }); const materialized = materializeSnapshotFromEvents({ events }); const snapshot = buildStoreSnapshot({ events, materialized }); @@ -633,8 +633,12 @@ export function createTicketsStore(opts: { } }; - const loadStoreContext = async (): Promise => { - const root = await git.ensureCheckedOut(); + const loadStoreContext = async (input?: { + readonly refreshRemote?: boolean; + }): Promise => { + const root = await git.ensureCheckedOut({ + refreshRemote: input?.refreshRemote, + }); const journalSignature = await projection.computeJournalSignature({ ticketsRoot: root, }); @@ -662,8 +666,10 @@ export function createTicketsStore(opts: { }; }; - const materializeTickets = async (): Promise> => { - const context = await loadStoreContext(); + const materializeTickets = async (input?: { + readonly refreshRemote?: boolean; + }): Promise> => { + const context = await loadStoreContext(input); return new Map( context.snapshot.tickets.map( (ticket) => [ticket.ticketId, ticket] as const @@ -679,7 +685,7 @@ export function createTicketsStore(opts: { | { readonly ok: true; readonly changed?: boolean } | { readonly ok: false; readonly error: string } > => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); const current = tickets.get(input.ticketId); if (!current) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; @@ -710,11 +716,14 @@ export function createTicketsStore(opts: { const appendEventsAndRefresh = async (input: { readonly events: readonly TicketEvent[]; + readonly refreshRemote?: boolean; }): Promise< | { readonly ok: true; readonly appendedCount: number } | { readonly ok: false; readonly error: string } > => { - const context = await loadStoreContext(); + const context = await loadStoreContext({ + refreshRemote: input.refreshRemote, + }); const seenIdempotencyKeys = new Set( context.events.map((event) => event.idempotencyKey) ); @@ -838,7 +847,7 @@ export function createTicketsStore(opts: { }, updateTicket: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); const current = tickets.get(input.ticketId); if (!current) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; @@ -883,7 +892,10 @@ export function createTicketsStore(opts: { return { ok: true, changed: false }; } - const wrote = await appendEventsAndRefresh({ events }); + const wrote = await appendEventsAndRefresh({ + events, + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -924,7 +936,7 @@ export function createTicketsStore(opts: { }, appendComment: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); const current = tickets.get(input.ticketId); if (!current) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; @@ -959,7 +971,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - const wrote = await appendEventsAndRefresh({ events: [event] }); + const wrote = await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -981,7 +996,7 @@ export function createTicketsStore(opts: { }, appendReviewNote: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } @@ -1006,7 +1021,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - const wrote = await appendEventsAndRefresh({ events: [event] }); + const wrote = await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -1025,7 +1043,7 @@ export function createTicketsStore(opts: { }, appendDocument: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } @@ -1048,7 +1066,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - const wrote = await appendEventsAndRefresh({ events: [event] }); + const wrote = await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -1068,7 +1089,7 @@ export function createTicketsStore(opts: { }, linkCommentExternalId: async (input) => { - const { snapshot } = await loadStoreContext(); + const { snapshot } = await loadStoreContext({ refreshRemote: true }); const comments = snapshot.commentsByTicket.get(input.ticketId) ?? []; const current = comments.find( (comment) => comment.commentId === input.commentId @@ -1097,11 +1118,14 @@ export function createTicketsStore(opts: { actor: input.actor, }); - return await appendEventsAndRefresh({ events: [event] }); + return await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); }, recordSyncCheckpoint: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } @@ -1120,7 +1144,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - const wrote = await appendEventsAndRefresh({ events: [event] }); + const wrote = await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -1137,7 +1164,7 @@ export function createTicketsStore(opts: { }, recordSyncConflict: async (input) => { - const tickets = await materializeTickets(); + const tickets = await materializeTickets({ refreshRemote: true }); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } @@ -1156,7 +1183,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - const wrote = await appendEventsAndRefresh({ events: [event] }); + const wrote = await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); if (!wrote.ok) { return wrote; } @@ -1172,7 +1202,7 @@ export function createTicketsStore(opts: { }, resolveSyncConflict: async (input) => { - const { snapshot } = await loadStoreContext(); + const { snapshot } = await loadStoreContext({ refreshRemote: true }); const conflicts = snapshot.conflictsByTicket.get(input.ticketId) ?? []; const current = conflicts.find( (conflict) => conflict.conflictId === input.conflictId @@ -1195,7 +1225,10 @@ export function createTicketsStore(opts: { actor: input.actor, }); - return await appendEventsAndRefresh({ events: [event] }); + return await appendEventsAndRefresh({ + events: [event], + refreshRemote: true, + }); }, readSnapshot: async () => { diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index 3c552dc4..45cd0e65 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -15,7 +15,10 @@ const DEFAULT_MUTATION_LOCK_TIMEOUT_MS = 30_000; const MAX_PUSH_ATTEMPTS = 3; export type TicketsGitChannel = { - readonly ensureCheckedOut: () => Promise; + readonly ensureCheckedOut: (input?: { + readonly forceFreshCheckout?: boolean; + readonly refreshRemote?: boolean; + }) => Promise; readonly appendEvents: (input: { readonly events: readonly Record[]; }) => Promise< @@ -447,6 +450,13 @@ export function createGitTicketsChannel(opts: { return null; }; + const hasLocalTicketsBranch = async (): Promise => { + const localBranch = await runGitDir({ + args: ["rev-parse", "--verify", localBranchRef], + }); + return localBranch.ok; + }; + const refreshRemoteTrackingRefs = async (input: { readonly remoteUrl: string | null; }): Promise< @@ -623,6 +633,7 @@ export function createGitTicketsChannel(opts: { }; const checkoutHead = async (input: { + readonly allowRemoteFetchFailureFallback?: boolean; readonly remoteUrl: string | null; }): Promise< | { readonly ok: true; readonly pushRef: string } @@ -630,6 +641,7 @@ export function createGitTicketsChannel(opts: { > => { await rm(worktreeDir, { recursive: true, force: true }); await mkdir(worktreeDir, { recursive: true }); + const allowLocalFallback = input.allowRemoteFetchFailureFallback === true; if (input.remoteUrl) { let canCheckoutRemote = false; @@ -663,7 +675,13 @@ export function createGitTicketsChannel(opts: { return { ok: false, error: `git fetch failed: ${legacyFetch.error}` }; } } else if (!fetched.missing) { - return { ok: false, error: `git fetch failed: ${fetched.error}` }; + if (allowLocalFallback) { + opts.logger.warn({ + message: `tickets git fetch failed during checkout, falling back to local branch initialization: ${fetched.error}`, + }); + } else { + return { ok: false, error: `git fetch failed: ${fetched.error}` }; + } } if (canCheckoutRemote) { @@ -698,7 +716,7 @@ export function createGitTicketsChannel(opts: { } const localRef = await runGitDir({ - args: ["rev-parse", "--verify", branch], + args: ["rev-parse", "--verify", localBranchRef], }); if (!localRef.ok) { const orphan = await runGitDir({ @@ -835,6 +853,7 @@ export function createGitTicketsChannel(opts: { const ensureCheckedOut = async (input?: { readonly forceFreshCheckout?: boolean; + readonly refreshRemote?: boolean; }): Promise< | { readonly ok: true; @@ -846,10 +865,15 @@ export function createGitTicketsChannel(opts: { await ensureDirs(); await ensureBareRepo(); await ensureSparseCheckout(); - const { remoteUrl } = await ensureRemote(); - const refreshed = await refreshRemoteTrackingRefs({ remoteUrl }); - if (!refreshed.ok) { - return refreshed; + const refreshRemote = input?.refreshRemote !== false; + const remote = await ensureRemote(); + if (refreshRemote) { + const refreshed = await refreshRemoteTrackingRefs({ + remoteUrl: remote.remoteUrl, + }); + if (!refreshed.ok) { + return refreshed; + } } if ( @@ -858,7 +882,7 @@ export function createGitTicketsChannel(opts: { ) { const pushRef = refMode === "hidden" && legacyRemoteRef ? legacyRemoteRef : remoteRef; - return { ok: true, remoteUrl, pushRef }; + return { ok: true, remoteUrl: remote.remoteUrl, pushRef }; } const preferredTrackingRef = await resolvePreferredTrackingRef(); @@ -870,20 +894,33 @@ export function createGitTicketsChannel(opts: { preferredTrackingRef === legacyTrackingRef && legacyRemoteRef ? legacyRemoteRef : remoteRef; - return { ok: true, remoteUrl, pushRef }; + return { ok: true, remoteUrl: remote.remoteUrl, pushRef }; } - const checkedOut = await checkoutHead({ remoteUrl }); + const hasLocalBranch = await hasLocalTicketsBranch(); + const checkoutRemoteUrl = + refreshRemote || !hasLocalBranch ? remote.remoteUrl : null; + + const checkedOut = await checkoutHead({ + allowRemoteFetchFailureFallback: !refreshRemote && hasLocalBranch, + remoteUrl: checkoutRemoteUrl, + }); if (!checkedOut.ok) { return checkedOut; } - const migratedLegacy = await mergeLegacyRefIntoCurrentBranch({ remoteUrl }); + const migratedLegacy = await mergeLegacyRefIntoCurrentBranch({ + remoteUrl: checkoutRemoteUrl, + }); if (!migratedLegacy.ok) { return migratedLegacy; } - return { ok: true, remoteUrl, pushRef: checkedOut.pushRef }; + return { + ok: true, + remoteUrl: remote.remoteUrl, + pushRef: checkedOut.pushRef, + }; }; const resolveEventsPath = (tsSeconds: number): string => { @@ -1376,8 +1413,8 @@ export function createGitTicketsChannel(opts: { }; return { - ensureCheckedOut: async () => { - const checkedOut = await ensureCheckedOut(); + ensureCheckedOut: async (input) => { + const checkedOut = await ensureCheckedOut(input); if (!checkedOut.ok) { throw new Error(checkedOut.error); } diff --git a/src/lib/lifecycle-runtime.ts b/src/lib/lifecycle-runtime.ts index 49fd82d0..29bce4f0 100644 --- a/src/lib/lifecycle-runtime.ts +++ b/src/lib/lifecycle-runtime.ts @@ -9,6 +9,8 @@ export type LifecycleStateProcess = { readonly name: string; readonly windowName: string; readonly logPath: string; + readonly panePid?: number; + readonly processGroupId?: number; }; export type LifecycleStateEntry = { @@ -250,13 +252,32 @@ function parseLifecycleStateProcess( if (!(name && windowName && logPath)) { return null; } + const panePid = parseLifecycleOptionalPositiveInteger(value.panePid); + const processGroupId = parseLifecycleOptionalPositiveInteger( + value.processGroupId + ); return { name, windowName, logPath, + ...(panePid ? { panePid } : {}), + ...(processGroupId ? { processGroupId } : {}), }; } +function parseLifecycleOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value > 0) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0) { + return parsed; + } + } + return null; +} + async function writeLifecycleStateFile(opts: { readonly projectDir: string; readonly state: LifecycleStateFile; diff --git a/tests/project-lifecycle-processes.test.ts b/tests/project-lifecycle-processes.test.ts new file mode 100644 index 00000000..f4c1e8b3 --- /dev/null +++ b/tests/project-lifecycle-processes.test.ts @@ -0,0 +1,150 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + collectDescendantProcessGroupIds, + parseProcessSnapshotOutput, + resolveLifecycleProcessGroupIdsForTmuxState, + wrapLifecyclePersistentCommand, +} from "../src/commands/project.ts"; +import { readLifecycleState } from "../src/lib/lifecycle-runtime.ts"; + +const tempDirs = new Set(); + +afterEach(async () => { + for (const tempDir of tempDirs) { + await rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +test("readLifecycleState preserves lifecycle pane and process group metadata", async () => { + const projectDir = await createLifecycleProjectDir(); + const statePath = resolve(projectDir, ".internal", "lifecycle", "state.json"); + + await writeFile( + statePath, + `${JSON.stringify( + { + entries: [ + { + composeProject: "event-agent", + projectName: "event-agent", + branch: "feature-cleanup", + sessionName: "event-agent--lifecycle-feature-cleanup", + backend: "tmux", + updatedAt: "2026-04-01T14:00:00.000Z", + processes: [ + { + name: "proxy", + windowName: "proxy", + logPath: "/tmp/event-agent.log", + panePid: "12345", + processGroupId: 67_890, + }, + ], + }, + ], + }, + null, + 2 + )}\n` + ); + + const entries = await readLifecycleState({ projectDir }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.processes).toEqual([ + { + name: "proxy", + windowName: "proxy", + logPath: "/tmp/event-agent.log", + panePid: 12_345, + processGroupId: 67_890, + }, + ]); +}); + +test("parseProcessSnapshotOutput ignores malformed rows", () => { + expect( + parseProcessSnapshotOutput( + ["101 1 101", "bad row", "202 x 202", "303 101 303 extra", ""].join("\n") + ) + ).toEqual([ + { pid: 101, ppid: 1, processGroupId: 101 }, + { pid: 303, ppid: 101, processGroupId: 303 }, + ]); +}); + +test("collectDescendantProcessGroupIds returns root and descendant groups once", () => { + const groups = collectDescendantProcessGroupIds({ + snapshot: [ + { pid: 100, ppid: 1, processGroupId: 100 }, + { pid: 101, ppid: 100, processGroupId: 101 }, + { pid: 102, ppid: 100, processGroupId: 101 }, + { pid: 103, ppid: 101, processGroupId: 103 }, + { pid: 200, ppid: 1, processGroupId: 200 }, + ], + rootPids: [100, 999, 100], + }); + + expect(groups).toEqual([100, 101, 103]); +}); + +test("resolveLifecycleProcessGroupIdsForTmuxState ignores stale persisted ids", () => { + const groups = resolveLifecycleProcessGroupIdsForTmuxState({ + lifecycleEntry: { + composeProject: "event-agent", + projectName: "event-agent", + branch: "feature-cleanup", + sessionName: "event-agent--lifecycle-feature-cleanup", + backend: "tmux", + updatedAt: "2026-04-01T14:00:00.000Z", + processes: [ + { + name: "proxy", + windowName: "proxy", + logPath: "/tmp/event-agent.log", + panePid: 99_999, + processGroupId: 99_999, + }, + ], + }, + panePidsByWindow: new Map([["proxy", [100]]]), + snapshot: [ + { pid: 100, ppid: 1, processGroupId: 100 }, + { pid: 101, ppid: 100, processGroupId: 101 }, + { pid: 99_999, ppid: 1, processGroupId: 99_999 }, + ], + }); + + expect(groups).toEqual([100, 101]); +}); + +test("wrapLifecyclePersistentCommand uses external kill for process-group cleanup", () => { + const script = wrapLifecyclePersistentCommand({ + command: "bun run proxy", + logPath: "/tmp/event-agent.log", + serviceName: "proxy", + }); + + expect(script).toContain('/bin/kill -TERM -- "-$cmd_pid"'); + expect(script).toContain('/usr/bin/kill -TERM -- "-$cmd_pid"'); + expect(script).not.toContain( + 'kill -TERM -- "-$cmd_pid" 2>/dev/null || kill "$cmd_pid" 2>/dev/null || true' + ); +}); + +async function createLifecycleProjectDir(): Promise { + const root = await mkdtemp(join(tmpdir(), "hack-lifecycle-processes-")); + tempDirs.add(root); + const projectDir = resolve(root, ".hack"); + + await mkdir(resolve(projectDir, ".internal", "lifecycle"), { + recursive: true, + }); + + return projectDir; +} diff --git a/tests/tickets-git-channel.test.ts b/tests/tickets-git-channel.test.ts index df73c7d9..34ecf4ac 100644 --- a/tests/tickets-git-channel.test.ts +++ b/tests/tickets-git-channel.test.ts @@ -464,6 +464,122 @@ test("repair reapplies cleanup after a non-fast-forward push retry", async () => expect(eventsText.stdout).toContain('"eventId":"event-2"'); }); +test("ensureCheckedOut can reuse the local tickets branch without refreshing remotes", async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-git-local-checkout-", + }); + + const channel = __testOnly.createGitTicketsChannel({ + projectRoot, + config: { + enabled: true, + branch: "hack/tickets", + refMode: "hidden", + remote: "origin", + forceBareClone: false, + }, + logger: { + info: (_input: { message: string }) => {}, + warn: (_input: { message: string }) => {}, + }, + }); + + const initialWorktree = await channel.ensureCheckedOut({ + refreshRemote: false, + }); + expect( + await Bun.file(resolve(initialWorktree, ".hack/tickets/README.md")).text() + ).toContain("Tickets ref for hack-cli"); + + await run({ + cwd: projectRoot, + cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], + }); + + const worktree = await channel.ensureCheckedOut({ refreshRemote: false }); + + expect( + await Bun.file(resolve(worktree, ".hack/tickets/README.md")).text() + ).toContain("Tickets ref for hack-cli"); +}); + +test("ensureCheckedOut does not poison a fresh clone after an unreachable first remote", async () => { + const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); + tempRoots.push(remoteRoot); + await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); + + const writerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-git-writer-recovery-", + }); + await run({ + cwd: writerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + + const writerChannel = __testOnly.createGitTicketsChannel({ + projectRoot: writerRoot, + config: { + enabled: true, + branch: "hack/tickets", + refMode: "hidden", + remote: "origin", + forceBareClone: false, + }, + logger: { + info: (_input: { message: string }) => {}, + warn: (_input: { message: string }) => {}, + }, + }); + expect( + await writerChannel.appendEvents({ + events: [ + createTicketEvent({ + eventId: "event-1", + ticketId: "T-AAAAAAA111", + ts: 1, + }), + ], + }) + ).toEqual({ ok: true }); + + const readerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-git-reader-recovery-", + }); + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], + }); + + const readerChannel = __testOnly.createGitTicketsChannel({ + projectRoot: readerRoot, + config: { + enabled: true, + branch: "hack/tickets", + refMode: "hidden", + remote: "origin", + forceBareClone: false, + }, + logger: { + info: (_input: { message: string }) => {}, + warn: (_input: { message: string }) => {}, + }, + }); + + await expect(readerChannel.ensureCheckedOut()).rejects.toThrow(); + + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "set-url", "origin", remoteRoot], + }); + + const worktree = await readerChannel.ensureCheckedOut(); + expect( + await Bun.file( + resolve(worktree, ".hack/tickets/events/events-1970-01.jsonl") + ).text() + ).toContain('"eventId":"event-1"'); +}); + async function createTempGitProject(input: { readonly prefix: string; }): Promise { diff --git a/tests/tickets-store.test.ts b/tests/tickets-store.test.ts index 9dc16020..f7fd0d76 100644 --- a/tests/tickets-store.test.ts +++ b/tests/tickets-store.test.ts @@ -5,6 +5,8 @@ import { readdir, readFile, rm, + stat, + utimes, writeFile, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; @@ -17,6 +19,7 @@ import { buildTicketProvenance, findTicketRemoteLink, } from "../src/control-plane/extensions/tickets/provenance.ts"; +import { createTicketsSqliteProjection } from "../src/control-plane/extensions/tickets/sqlite-projection.ts"; import { createTicketsStore } from "../src/control-plane/extensions/tickets/store.ts"; import { createGitTicketsChannel } from "../src/control-plane/extensions/tickets/tickets-git-channel.ts"; import { createDefaultControlPlaneConfig } from "../src/control-plane/sdk/config.ts"; @@ -689,6 +692,189 @@ test("tickets store persists a sqlite projection and rebuilds it when deleted", expect(await Bun.file(projectionPath).exists()).toBe(true); }, 20_000); +test("tickets store hydrates remote tickets on a fresh clone without an explicit sync", async () => { + const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); + tempRoots.push(remoteRoot); + await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); + + const writerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-writer-", + }); + await run({ + cwd: writerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + const writerStore = await createStore({ projectRoot: writerRoot }); + + const created = await writerStore.createTicket({ + title: "Remote hydration ticket", + body: "Should appear on a fresh clone read path.", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const readerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-reader-", + }); + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + const readerStore = await createStore({ projectRoot: readerRoot }); + + const tickets = await readerStore.listTickets(); + expect(tickets.map((ticket) => ticket.title)).toContain( + "Remote hydration ticket" + ); +}, 20_000); + +test("tickets store does not poison a fresh clone after an unreachable first remote", async () => { + const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); + tempRoots.push(remoteRoot); + await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); + + const writerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-writer-recovery-", + }); + await run({ + cwd: writerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + const writerStore = await createStore({ projectRoot: writerRoot }); + + const created = await writerStore.createTicket({ + title: "Recovered remote hydration ticket", + body: "Should still load after the first remote error is fixed.", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const readerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-reader-recovery-", + }); + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "add", "origin", "ssh://127.0.0.1:1/does-not-exist"], + }); + const readerStore = await createStore({ projectRoot: readerRoot }); + + await expect(readerStore.listTickets()).rejects.toThrow(); + + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "set-url", "origin", remoteRoot], + }); + + const tickets = await readerStore.listTickets(); + expect(tickets.map((ticket) => ticket.title)).toContain( + "Recovered remote hydration ticket" + ); +}, 20_000); + +test("tickets store refreshes remote state before validating mutation targets", async () => { + const remoteRoot = await mkdtemp(join(tmpdir(), "hack-cli-tickets-remote-")); + tempRoots.push(remoteRoot); + await run({ cwd: remoteRoot, cmd: ["git", "init", "--bare"] }); + + const writerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-writer-refresh-", + }); + await run({ + cwd: writerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + const writerStore = await createStore({ projectRoot: writerRoot }); + + const baseTicket = await writerStore.createTicket({ + title: "Base ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(baseTicket.ok).toBe(true); + if (!baseTicket.ok) { + throw new Error(baseTicket.error); + } + + const readerRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-reader-refresh-", + }); + await run({ + cwd: readerRoot, + cmd: ["git", "remote", "add", "origin", remoteRoot], + }); + const readerStore = await createStore({ projectRoot: readerRoot }); + + const initialTickets = await readerStore.listTickets(); + expect(initialTickets.map((ticket) => ticket.title)).toContain("Base ticket"); + + const created = await writerStore.createTicket({ + title: "Remote-only ticket", + owner: "hack", + source: "hack", + actor: "creator@hack", + }); + expect(created.ok).toBe(true); + if (!created.ok) { + throw new Error(created.error); + } + + const updated = await readerStore.setStatus({ + ticketId: created.ticket.ticketId, + status: "in_progress", + actor: "reader@hack", + }); + expect(updated).toEqual({ ok: true, changed: true }); + + const refreshed = await readerStore.getTicket({ + ticketId: created.ticket.ticketId, + }); + expect(refreshed?.status).toBe("in_progress"); +}, 20_000); + +test("tickets sqlite projection signature tracks journal file metadata instead of reading full contents", async () => { + const projectRoot = await createTempGitProject({ + prefix: "hack-cli-tickets-signature-", + }); + const projection = createTicketsSqliteProjection({ projectRoot }); + const eventsDir = resolve(projectRoot, ".hack/tickets/events"); + const journalPath = resolve(eventsDir, "events-2026-04.jsonl"); + + await mkdir(eventsDir, { recursive: true }); + await writeFile(journalPath, '{"ticketId":"T-ONE"}\n'); + const baseMtimeSeconds = 1_700_000_000; + await utimes(journalPath, baseMtimeSeconds + 0.123, baseMtimeSeconds + 0.123); + + const initial = await projection.computeJournalSignature({ + ticketsRoot: projectRoot, + }); + + await writeFile(journalPath, '{"ticketId":"T-TWO"}\n'); + await utimes(journalPath, baseMtimeSeconds + 0.789, baseMtimeSeconds + 0.789); + + const updatedMetadata = await stat(journalPath); + expect(Math.trunc(updatedMetadata.mtimeMs)).toBe( + Math.trunc((baseMtimeSeconds + 0.789) * 1000) + ); + expect(updatedMetadata.mtimeMs).not.toBe((baseMtimeSeconds + 0.123) * 1000); + + const updated = await projection.computeJournalSignature({ + ticketsRoot: projectRoot, + }); + + expect(updated).not.toBe(initial); +}, 20_000); + test("normalized ticket adapter preserves compatibility while exposing provenance and documents", () => { const summary = { ticketId: "T-00042",