@@ -14,6 +14,10 @@ import {
1414
1515const execFileAsync = promisify ( execFile ) ;
1616const DEFAULT_PROFILE_ROOT = "/tmp/podnotes-obsidian-e2e" ;
17+ // Sidecar written at the instance root recording which worktree the instance
18+ // belongs to. The teardown reaper reads it to reap an instance only once its
19+ // worktree is gone (a removed/merged worktree) — the reliable leak signal.
20+ export const INSTANCE_MARKER_FILE = "podnotes-e2e-instance.json" ;
1721const DEFAULT_OBSIDIAN_APP = "Obsidian" ;
1822const DEFAULT_OBSIDIAN_BIN = "obsidian" ;
1923const READY_TIMEOUT_MS = 30_000 ;
@@ -168,6 +172,14 @@ export async function prepareObsidianProfile(options) {
168172 } ,
169173 } ) ;
170174
175+ // Record which worktree this instance belongs to so the teardown reaper can
176+ // reap it once that worktree is removed (see INSTANCE_MARKER_FILE).
177+ await writeJson ( path . join ( options . instancePath , INSTANCE_MARKER_FILE ) , {
178+ worktreePath : options . worktreePath ,
179+ vaultName : options . vaultName ,
180+ vaultPath : options . vaultPath ,
181+ } ) ;
182+
171183 return {
172184 obsidianJsonPath,
173185 userDataPath,
@@ -406,6 +418,29 @@ function shellQuote(value) {
406418 return `'${ String ( value ) . replaceAll ( "'" , "'\\''" ) } '` ;
407419}
408420
421+ // Self-healing safety net: before launching our own instance, reap any leaked
422+ // instances whose backing worktree is gone (e.g. removed on merge without the
423+ // orca archive hook running). Best-effort — a reap failure must never block a
424+ // start. The reaper is imported lazily so the static module graph stays acyclic
425+ // (the stop module imports our option resolver; we only need its reaper at
426+ // runtime). Logs go to stderr so `--print-env` keeps stdout to `export …` lines.
427+ export async function reapStaleInstances ( options ) {
428+ try {
429+ const { reapOrphanedInstances } = await import (
430+ "./stop-obsidian-e2e-instance.mjs"
431+ ) ;
432+ await reapOrphanedInstances ( {
433+ profileRoot : options . profileRoot ,
434+ exceptInstancePath : options . instancePath ,
435+ log : console . error ,
436+ } ) ;
437+ } catch ( error ) {
438+ console . error (
439+ `Skipping stale-instance reap: ${ error instanceof Error ? error . message : error } ` ,
440+ ) ;
441+ }
442+ }
443+
409444async function main ( ) {
410445 const rawOptions = parseArgs ( process . argv . slice ( 2 ) ) ;
411446 if ( rawOptions . help ) {
@@ -414,6 +449,7 @@ async function main() {
414449 }
415450
416451 const options = resolveInstanceOptions ( rawOptions ) ;
452+ await reapStaleInstances ( options ) ;
417453 const provisionResult = await provisionVault ( options ) ;
418454 const profileResult = await prepareObsidianProfile ( options ) ;
419455 options . userDataPath = profileResult . userDataPath ;
0 commit comments