spexcode 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -1006,10 +1006,10 @@ if (cmd === 'serve') {
1006
1006
  if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
1007
1007
  sessionMaintenance().finishExternalOperation(ticket, owner)
1008
1008
  } else if (sub === 'shared-runtime-spawn') {
1009
- const [cwd, logFile, pidFile, isolationFile, command] = process.argv.slice(4, 9)
1009
+ const [cwd, logFile, pidFile, receiptFile, command] = process.argv.slice(4, 9)
1010
1010
  const args = process.argv.slice(9)
1011
- if (!cwd || !logFile || !pidFile || !isolationFile || !command) {
1012
- console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <isolation-file> <command> [args...]')
1011
+ if (!cwd || !logFile || !pidFile || !receiptFile || !command) {
1012
+ console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <receipt-file> <command> [args...]')
1013
1013
  process.exit(2)
1014
1014
  }
1015
1015
  const { readFileSync } = await import('node:fs')
@@ -1029,7 +1029,7 @@ if (cmd === 'serve') {
1029
1029
  delete env.SPEXCODE_MAINTENANCE_SESSION_ID
1030
1030
  delete env.SPEXCODE_SESSION_ID
1031
1031
  const runtime = await runSessionOperation({ op: 'shared-spawn', sessionId, ...(delegateChannelPresent ? { delegate } : {}) }, () =>
1032
- spawnDetachedRuntime({ cwd, logFile, pidFile, isolationFile, command, args, env }))
1032
+ spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env }))
1033
1033
  console.log(runtime.pid)
1034
1034
  } else if (sub === 'codex-launch') {
1035
1035
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
@@ -282,7 +282,8 @@ see LAUNCHERS.
282
282
 
283
283
  ── LAYOUT (spexcode.json — portable; set only for a NON-DEFAULT repo layout) ──
284
284
  main path to the source-of-truth checkout. Default: the \`main\` worktree.
285
- mainBranch the source-of-truth BRANCH worktrees fork from. Default: auto-detected.
285
+ mainBranch the stable source-of-truth BRANCH worktrees fork from. spex init stamps the root checkout's
286
+ branch at adoption; an older omitted value uses the conventional main.
286
287
  branchPrefix how a node branch is named. Default "node/".
287
288
  Example — a repo whose trunk is \`staging\`, not \`main\`:
288
289
  { "mainBranch": "staging" }
@@ -16,7 +16,7 @@ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from '.
16
16
  import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
17
17
  import { git } from './git.js'
18
18
  import { shQuote } from './sh.js'
19
- import { processStartToken, processTopology } from './process-identity.js'
19
+ import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
20
 
21
21
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
22
22
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -53,13 +53,13 @@ export type SharedRuntimeDescriptor = {
53
53
  key: string
54
54
  label: string
55
55
  pidFile: string
56
- isolationFile: string
56
+ receiptFile: string
57
57
  // Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
58
58
  // per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
59
59
  residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
60
60
  // Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
61
61
  // ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
62
- mutationGuard?: (targetReferenceId: string) => Promise<SharedRuntimeMutationGuard>
62
+ mutationGuard?: (targetReferenceId: string, opts?: { coldReceipt?: unknown }) => Promise<SharedRuntimeMutationGuard>
63
63
  probe(): Promise<SharedRuntimeProbe>
64
64
  }
65
65
  export type SharedRuntimeMutationGuard = {
@@ -67,6 +67,7 @@ export type SharedRuntimeMutationGuard = {
67
67
  referenceIds: string[]
68
68
  targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
69
69
  descendantIds: string[]
70
+ coldTeardownAuthorized?: boolean
70
71
  error?: string
71
72
  }
72
73
  export type SharedRuntimeProbe = {
@@ -79,6 +80,10 @@ export type SharedRuntimeProbe = {
79
80
  error?: string
80
81
  }
81
82
 
83
+ export type HarnessColdPreflight =
84
+ | { ok: true; alreadyCold?: boolean; receipt?: unknown }
85
+ | { ok: false; reason: string }
86
+
82
87
  export type AdapterLoadedReferenceState = {
83
88
  healthy: boolean
84
89
  loaded: boolean
@@ -273,14 +278,16 @@ export interface Harness {
273
278
  cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
274
279
  // Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
275
280
  // unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
276
- coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold?: boolean } | { ok: false; reason: string }>
281
+ // Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
282
+ // stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
283
+ coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
277
284
  // A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
278
285
  // this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
279
286
  coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
280
287
  // Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
281
288
  // reference or return a loud reason; adapters without such a resident reference return {ok:true}.
282
- coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
283
- restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
289
+ coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
290
+ restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
284
291
  // Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
285
292
  // references and protect a sibling-owned control plane without learning harness command names.
286
293
  sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
@@ -422,37 +429,26 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
422
429
  return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
423
430
  }
424
431
  export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
425
- export const codexAppServerIsolation = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
432
+ export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
426
433
  type CodexRuntimeGenerationProof = Readonly<{
427
- pid: number
428
- startToken: string
429
- processGroupId: number
430
- sessionId: number
431
- isolation: string
434
+ identity: VerifiedDetachedRuntime
432
435
  socket: Readonly<{ path: string; dev: number; ino: number }>
433
436
  }>
434
437
  function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
435
438
  try {
436
439
  const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
437
- const start = processStartToken(pid)
438
- const scope = readFileSync(codexAppServerIsolation(dir), 'utf8').trim()
439
- const topology = processTopology(pid)
440
+ const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
440
441
  const socketPath = codexAppServerSock(dir)
441
442
  const socket = statSync(socketPath)
442
- if (!(pid > 0) || !start || !topology || topology.startToken !== start || topology.processGroupId !== pid || topology.sessionId !== pid ||
443
- scope !== `detached-v3 ${pid} ${start} ${pid} ${pid}` || !socket.isSocket()) return null
443
+ if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
444
444
  return Object.freeze({
445
- pid,
446
- startToken: start,
447
- processGroupId: topology.processGroupId,
448
- sessionId: topology.sessionId,
449
- isolation: scope,
445
+ identity: detached.identity,
450
446
  socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
451
447
  })
452
448
  } catch { return null }
453
449
  }
454
450
  const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
455
- `${proof.pid}|${proof.startToken}|${proof.processGroupId}|${proof.sessionId}|${proof.isolation}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
451
+ `${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
456
452
  function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
457
453
  const proof = codexRuntimeGenerationProof(dir)
458
454
  return proof ? codexRuntimeGenerationToken(proof) : null
@@ -673,14 +669,14 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
673
669
  const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
674
670
  const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
675
671
  const pid = codexAppServerPid(dir)
676
- const isolation = codexAppServerIsolation(dir)
672
+ const receipt = codexAppServerReceipt(dir)
677
673
  const log = join(dir, 'codex-app-server.log')
678
674
  const lock = join(dir, 'codex-app-server.lock')
679
675
  const script = [
680
676
  `dir=${shQuote(dir)}`,
681
677
  `sock=${shQuote(sock)}`,
682
678
  `pid=${shQuote(pid)}`,
683
- `isolation=${shQuote(isolation)}`,
679
+ `receipt=${shQuote(receipt)}`,
684
680
  `log=${shQuote(log)}`,
685
681
  `lock=${shQuote(lock)}`,
686
682
  // codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
@@ -724,10 +720,10 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
724
720
  // mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
725
721
  // inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
726
722
  // The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
727
- // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane despite a matching
728
- // stamp. The internal helper uses child_process detached=true, records PID/start plus the observed pgrp/sid,
729
- // and refuses unless the live process is its own session leader. The stop guard re-reads that topology.
730
- ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$isolation" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
723
+ // a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
724
+ // uses child_process detached=true, then the process adapter publishes a private receipt only after proving
725
+ // exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
726
+ ` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$receipt" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
731
727
  ' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
732
728
  'fi',
733
729
  'rmdir "$lockd" 2>/dev/null',
@@ -890,7 +886,14 @@ export const CODEX_THREAD_SOURCE_KINDS = [
890
886
  'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
891
887
  'subAgentThreadSpawn', 'subAgentOther', 'unknown',
892
888
  ] as const
893
- function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list', params: Record<string, unknown>, extractId: (item: unknown) => string | null, label: string): Promise<CodexPagedIdsResult> {
889
+ function codexPagedIds(
890
+ sock: string,
891
+ method: 'thread/list' | 'thread/loaded/list',
892
+ params: Record<string, unknown>,
893
+ extractId: (item: unknown) => string | null,
894
+ label: string,
895
+ onItem?: (item: unknown) => void,
896
+ ): Promise<CodexPagedIdsResult> {
894
897
  return new Promise((resolve) => {
895
898
  const conn: Socket = createConnection(sock)
896
899
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -914,6 +917,7 @@ function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list
914
917
  if (message.id !== requestId || !message.result) return
915
918
  const page = message.result as { data?: unknown; nextCursor?: unknown }
916
919
  if (Array.isArray(page.data)) for (const item of page.data) {
920
+ onItem?.(item)
917
921
  const id = extractId(item)
918
922
  if (typeof id === 'string') ids.add(id)
919
923
  }
@@ -992,14 +996,35 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
992
996
  // The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
993
997
  // a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
994
998
  export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
999
+ return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
1000
+ }
1001
+
1002
+ type CodexThreadCollectionResult =
1003
+ | { ok: true; ids: string[]; parentById: Map<string, string | null> }
1004
+ | { ok: false; error: string }
1005
+
1006
+ function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
995
1007
  const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
996
1008
  ? params.sourceKinds
997
1009
  : [...CODEX_THREAD_SOURCE_KINDS]
1010
+ const parentById = new Map<string, string | null>()
1011
+ const conflictingParents = new Set<string>()
998
1012
  return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
999
1013
  if (typeof item === 'string') return item
1000
1014
  const id = (item as { id?: unknown } | null)?.id
1001
1015
  return typeof id === 'string' ? id : null
1002
- }, 'thread/list')
1016
+ }, 'thread/list', (item) => {
1017
+ if (!item || typeof item !== 'object') return
1018
+ const row = item as { id?: unknown; parentThreadId?: unknown }
1019
+ if (typeof row.id !== 'string') return
1020
+ const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
1021
+ if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
1022
+ parentById.set(row.id, parent)
1023
+ }).then((result) => {
1024
+ if (!result.ok) return result
1025
+ if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
1026
+ return { ...result, parentById }
1027
+ })
1003
1028
  }
1004
1029
 
1005
1030
  async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
@@ -1029,28 +1054,194 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()):
1029
1054
  return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
1030
1055
  }
1031
1056
 
1032
- type CodexColdPreflight = { ok: true; alreadyCold?: boolean; guard: SharedRuntimeMutationGuard } | { ok: false; reason: string }
1033
- async function codexColdPreflight(threadId: string, dir = runtimeRoot()): Promise<CodexColdPreflight> {
1057
+ const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
1058
+ type CodexColdPlan = Readonly<{
1059
+ [CODEX_COLD_PLAN]: true
1060
+ kind: 'codex-cold-subtree-v1'
1061
+ threadId: string
1062
+ generation: string
1063
+ guard: SharedRuntimeMutationGuard
1064
+ descendantIds: readonly string[]
1065
+ parentEdges: readonly (readonly [string, string])[]
1066
+ subtreeIds: readonly string[]
1067
+ activeIds: readonly string[]
1068
+ archivedIds: readonly string[]
1069
+ }>
1070
+ type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
1071
+
1072
+ const sameIdSet = (left: readonly string[], right: readonly string[]) =>
1073
+ left.length === right.length && left.every((id) => right.includes(id))
1074
+
1075
+ const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
1076
+ left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
1077
+
1078
+ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
1079
+ if (!value || typeof value !== 'object') return false
1080
+ const plan = value as Partial<CodexColdPlan>
1081
+ return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
1082
+ typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
1083
+ Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
1084
+ Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
1085
+ }
1086
+
1087
+ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
1088
+ const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
1089
+ if (!generation || codexRuntimeGeneration(dir) !== generation)
1090
+ return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
1034
1091
  const sock = codexAppServerSock(dir)
1035
- const [guard, archivedList, activeList] = await Promise.all([
1036
- codexTargetMutationGuard(threadId, dir),
1092
+ const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
1093
+ codexLoadedReferenceIds(sock),
1094
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
1095
+ codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
1037
1096
  codexThreadList(sock, { archived: true, sourceKinds: [] }),
1038
1097
  codexThreadList(sock, { archived: false, sourceKinds: [] }),
1039
1098
  ])
1040
- if (guard.descendantIds.length)
1041
- return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); subtree archive is not proven safe` }
1042
- if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1099
+ if (codexRuntimeGeneration(dir) !== generation)
1100
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
1101
+ if (!loaded.ok) return { ok: false, reason: loaded.error }
1102
+ if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
1103
+ if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
1043
1104
  if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1044
1105
  if (!activeList.ok) return { ok: false, reason: activeList.error }
1045
- const inArchived = archivedList.ids.includes(threadId)
1046
- const inActive = activeList.ids.includes(threadId)
1047
- if (inArchived === inActive) return { ok: false, reason: `Codex thread ${threadId} has ambiguous disk collection state (archived=${inArchived}, active=${inActive})` }
1048
- if (guard.targetTurnPresence === 'active') return { ok: false, reason: `Codex thread ${threadId} has an active turn; terminal-state confirmation is required before archive` }
1049
- if (guard.targetTurnPresence === 'unknown') return { ok: false, reason: `Codex thread ${threadId} turn state is unknown; refusing to archive without terminal-state confirmation` }
1050
- if (guard.targetTurnPresence === 'none')
1051
- return inArchived ? { ok: true, alreadyCold: true, guard } : { ok: true, guard }
1052
- if (inArchived) return { ok: false, reason: `Codex thread ${threadId} is archived but remains loaded` }
1053
- return { ok: true, guard }
1106
+
1107
+ const activeDescendantSet = new Set(activeDescendants.ids)
1108
+ const archivedDescendantSet = new Set(archivedDescendants.ids)
1109
+ const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
1110
+ if (duplicateDescendants.length)
1111
+ return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
1112
+ const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
1113
+ if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
1114
+
1115
+ const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
1116
+ const depthById = new Map<string, number>()
1117
+ for (const id of descendantIds) {
1118
+ const seen = new Set([id])
1119
+ let cursor = id
1120
+ let depth = 0
1121
+ while (cursor !== threadId) {
1122
+ const next = parentById.get(cursor)
1123
+ if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
1124
+ if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
1125
+ seen.add(next)
1126
+ cursor = next
1127
+ depth++
1128
+ }
1129
+ depthById.set(id, depth)
1130
+ }
1131
+
1132
+ const activeSet = new Set(activeList.ids)
1133
+ const archivedSet = new Set(archivedList.ids)
1134
+ const subtreeIds = [...descendantIds, threadId]
1135
+ for (const id of subtreeIds) {
1136
+ const inActive = activeSet.has(id)
1137
+ const inArchived = archivedSet.has(id)
1138
+ if (!inActive && !inArchived)
1139
+ return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
1140
+ if (inActive && inArchived)
1141
+ return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
1142
+ if (id !== threadId) {
1143
+ const expectedActive = activeDescendantSet.has(id)
1144
+ if (inActive !== expectedActive)
1145
+ return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
1146
+ }
1147
+ }
1148
+
1149
+ const loadedSet = new Set(loaded.referenceIds)
1150
+ const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
1151
+ const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
1152
+ if (codexRuntimeGeneration(dir) !== generation)
1153
+ return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
1154
+ for (const { id, state } of turnStates) {
1155
+ if (!state.ok) return { ok: false, reason: state.error }
1156
+ if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
1157
+ if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
1158
+ if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
1159
+ }
1160
+
1161
+ const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
1162
+ const guard: SharedRuntimeMutationGuard = {
1163
+ healthy: true,
1164
+ referenceIds: [...loaded.referenceIds],
1165
+ targetTurnPresence,
1166
+ descendantIds: [...descendantIds],
1167
+ }
1168
+ const activeIds = [...activeDescendants.ids]
1169
+ .sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
1170
+ .concat(activeSet.has(threadId) ? [threadId] : [])
1171
+ const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
1172
+ const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
1173
+ const receipt: CodexColdPlan = Object.freeze({
1174
+ [CODEX_COLD_PLAN]: true as const,
1175
+ kind: 'codex-cold-subtree-v1',
1176
+ threadId,
1177
+ generation,
1178
+ guard,
1179
+ descendantIds: Object.freeze([...descendantIds]),
1180
+ parentEdges: Object.freeze(parentEdges),
1181
+ subtreeIds: Object.freeze([...subtreeIds]),
1182
+ activeIds: Object.freeze(activeIds),
1183
+ archivedIds: Object.freeze(archivedIds),
1184
+ })
1185
+ return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
1186
+ }
1187
+
1188
+ async function codexMutationGuard(
1189
+ threadId: string,
1190
+ dir = runtimeRoot(),
1191
+ opts: { coldReceipt?: unknown } = {},
1192
+ ): Promise<SharedRuntimeMutationGuard> {
1193
+ if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
1194
+ if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
1195
+ return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
1196
+ const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
1197
+ if (!current.ok) {
1198
+ const guard = await codexTargetMutationGuard(threadId, dir)
1199
+ return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
1200
+ }
1201
+ const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
1202
+ sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
1203
+ sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
1204
+ sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
1205
+ return {
1206
+ ...current.receipt.guard,
1207
+ healthy: authorized,
1208
+ coldTeardownAuthorized: authorized,
1209
+ ...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
1210
+ }
1211
+ }
1212
+
1213
+ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
1214
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1215
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1216
+ const sock = codexAppServerSock(dir)
1217
+ const [activeBefore, archivedBefore] = await Promise.all([
1218
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1219
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1220
+ ])
1221
+ if (!activeBefore.ok || !archivedBefore.ok)
1222
+ return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
1223
+ if (codexRuntimeGeneration(dir) !== plan.generation)
1224
+ return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
1225
+ const activeSet = new Set(activeBefore.ids)
1226
+ const archivedSet = new Set(archivedBefore.ids)
1227
+ if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
1228
+ return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
1229
+ if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
1230
+ return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
1231
+ const fence = { dir, generation: plan.generation }
1232
+ const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
1233
+ for (const id of restoreIds) {
1234
+ const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
1235
+ if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
1236
+ }
1237
+ const [activeAfter, archivedAfter] = await Promise.all([
1238
+ codexThreadList(sock, { archived: false, sourceKinds: [] }),
1239
+ codexThreadList(sock, { archived: true, sourceKinds: [] }),
1240
+ ])
1241
+ const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
1242
+ plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
1243
+ plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
1244
+ return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
1054
1245
  }
1055
1246
 
1056
1247
  // Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
@@ -1116,6 +1307,8 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
1116
1307
  const listener = await listenerAt(sock, 800)
1117
1308
  if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
1118
1309
  if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
1310
+ const generation = codexRuntimeGeneration(dir)
1311
+ if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
1119
1312
  return new Promise<SharedRuntimeProbe>((resolve) => {
1120
1313
  const conn: Socket = createConnection(sock)
1121
1314
  const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
@@ -1133,7 +1326,9 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
1133
1326
  settled = true
1134
1327
  clearTimeout(timer)
1135
1328
  try { conn.destroy() } catch { /* */ }
1136
- resolve(result)
1329
+ resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
1330
+ ? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
1331
+ : result)
1137
1332
  }
1138
1333
  const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
1139
1334
  timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
@@ -1898,75 +2093,67 @@ export const codexHarness: Harness = {
1898
2093
  const dir = runtimeRoot()
1899
2094
  const generationBefore = codexRuntimeGeneration(dir)
1900
2095
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1901
- const sock = codexAppServerSock(dir)
1902
- const [guard, archivedList, activeList] = await Promise.all([
1903
- codexTargetMutationGuard(threadId, dir),
1904
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1905
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1906
- ])
2096
+ const result = await codexColdPreflight(threadId, dir, generationBefore)
1907
2097
  if (codexRuntimeGeneration(dir) !== generationBefore)
1908
2098
  return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
1909
- if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); cold retirement is not proven safe` }
1910
- if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
1911
- if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1912
- if (!activeList.ok) return { ok: false, reason: activeList.error }
1913
- if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} is still loaded` }
1914
- const inArchived = archivedList.ids.includes(threadId)
1915
- const inActive = activeList.ids.includes(threadId)
1916
- if (!inArchived || inActive) return { ok: false, reason: `Codex thread ${threadId} is not uniquely in the archived collection (archived=${inArchived}, active=${inActive})` }
2099
+ if (!result.ok) return result
2100
+ if (!result.alreadyCold)
2101
+ return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
1917
2102
  return { ok: true, alreadyCold: true }
1918
2103
  },
1919
2104
  coldPreflight: async (rec) => {
1920
2105
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1921
- const result = await codexColdPreflight(rec.harnessSessionId)
1922
- return result.ok ? { ok: true, ...(result.alreadyCold ? { alreadyCold: true } : {}) } : result
2106
+ return codexColdPreflight(rec.harnessSessionId)
1923
2107
  },
1924
- coldRuntime: async (rec) => {
2108
+ coldRuntime: async (rec, suppliedReceipt) => {
1925
2109
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
1926
2110
  const threadId = rec.harnessSessionId
1927
- const sock = codexAppServerSock(runtimeRoot())
1928
- const generationBefore = codexRuntimeGeneration(runtimeRoot())
2111
+ const dir = runtimeRoot()
2112
+ const sock = codexAppServerSock(dir)
2113
+ const generationBefore = codexRuntimeGeneration(dir)
1929
2114
  if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
1930
- const preflight = await codexColdPreflight(threadId)
1931
- if (preflight && !preflight.ok) return preflight
1932
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during target guard' }
1933
- if (preflight?.ok && preflight.alreadyCold) return { ok: true }
1934
- const siblingBefore = preflight.guard.referenceIds.filter((referenceId) => referenceId !== threadId)
2115
+ if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
2116
+ return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
2117
+ const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
2118
+ if (frozenPlan && frozenPlan.generation !== generationBefore)
2119
+ return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
2120
+ const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
2121
+ if (!preflight.ok) return preflight
2122
+ const plan = frozenPlan ?? preflight.receipt
2123
+ if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
2124
+ !sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
2125
+ !sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
2126
+ !sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
2127
+ return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
2128
+ if (codexRuntimeGeneration(dir) !== plan.generation)
2129
+ return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
2130
+ if (plan.activeIds.length === 0) return { ok: true }
2131
+ const subtreeSet = new Set(plan.subtreeIds)
2132
+ const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
2133
+ const fence = { dir, generation: plan.generation }
2134
+
2135
+ const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
2136
+ const restored = await codexRestoreColdPlan(plan, dir)
2137
+ return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
2138
+ }
2139
+
1935
2140
  const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1936
- const [guard, archivedList, activeList] = await Promise.all([
1937
- codexTargetMutationGuard(threadId),
1938
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1939
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1940
- ])
1941
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
1942
- if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} acquired owned descendants during archive (${guard.descendantIds.join(', ')})` }
1943
- if (!guard.healthy) return { ok: false, reason: guard.error || 'Codex post-archive target guard is unhealthy' }
1944
- if (!archivedList.ok) return { ok: false, reason: archivedList.error }
1945
- if (!activeList.ok) return { ok: false, reason: activeList.error }
1946
- if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} remains loaded after thread/archive` }
1947
- if (!archivedList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} is absent from both loaded and archived collections` }
1948
- if (activeList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} remains in the non-archived collection` }
1949
- const afterIds = new Set(guard.referenceIds.filter((referenceId) => referenceId !== threadId))
2141
+ const after = await codexColdPreflight(threadId, dir, plan.generation)
2142
+ if (!after.ok) return after
2143
+ if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
2144
+ if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
2145
+ return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
2146
+ if (after.receipt.activeIds.length)
2147
+ return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
2148
+ if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
2149
+ return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
2150
+ const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
1950
2151
  if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
1951
2152
  return { ok: true }
1952
2153
  }
1953
- const fence = { dir: runtimeRoot(), generation: generationBefore }
1954
- const archived = await codexThreadMutation(sock, 'thread/archive', rec.harnessSessionId, fence)
1955
- if (!archived.ok) {
1956
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1957
- return { ok: false, reason: `${archived.error}; shared Codex app-server generation changed, so archive state is unknown and no compensation was attempted` }
1958
- // RPC transport failure is commit-unknown. Reconcile collections before deciding whether compensation is needed.
1959
- const [archivedList, activeList] = await Promise.all([
1960
- codexThreadList(sock, { archived: true, sourceKinds: [] }),
1961
- codexThreadList(sock, { archived: false, sourceKinds: [] }),
1962
- ])
1963
- if (archivedList.ok && archivedList.ids.includes(rec.harnessSessionId)) {
1964
- const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1965
- const stillActive = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1966
- const suffix = restored.ok && stillActive.ok && stillActive.ids.includes(rec.harnessSessionId) ? '' : '; compensation/reconciliation failed'
1967
- return { ok: false, reason: `${archived.error}${suffix}` }
1968
- }
1969
- return { ok: false, reason: archivedList.ok && activeList.ok ? archived.error : `${archived.error}; archive state is unknown and could not be reconciled` }
2154
+ for (const id of plan.activeIds) {
2155
+ const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
2156
+ if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
1970
2157
  }
1971
2158
  let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
1972
2159
  const verifyDeadline = Date.now() + 30_000
@@ -1976,15 +2163,15 @@ export const codexHarness: Harness = {
1976
2163
  if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
1977
2164
  }
1978
2165
  if (verified.ok) return verified
1979
- if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
1980
- return { ok: false, reason: `${verified.reason}; shared Codex app-server generation changed, so no compensation was attempted` }
1981
- const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
1982
- const active = await codexThreadList(sock, { archived: false, sourceKinds: [] })
1983
- const suffix = restored.ok && active.ok && active.ids.includes(rec.harnessSessionId) ? '' : '; compensation failed or archive state is unknown'
1984
- return { ok: false, reason: `${verified.reason}${suffix}` }
2166
+ return compensate(verified.reason)
1985
2167
  },
1986
- restoreRuntime: async (rec) => {
2168
+ restoreRuntime: async (rec, suppliedReceipt) => {
1987
2169
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
2170
+ if (suppliedReceipt !== undefined) {
2171
+ if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
2172
+ return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
2173
+ return codexRestoreColdPlan(suppliedReceipt)
2174
+ }
1988
2175
  const sock = codexAppServerSock(runtimeRoot())
1989
2176
  const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
1990
2177
  const [active, archived] = await Promise.all([
@@ -2006,7 +2193,7 @@ export const codexHarness: Harness = {
2006
2193
  key: 'codex-app-server',
2007
2194
  label: 'Codex app-server',
2008
2195
  pidFile: codexAppServerPid(runtimeDir),
2009
- isolationFile: codexAppServerIsolation(runtimeDir),
2196
+ receiptFile: codexAppServerReceipt(runtimeDir),
2010
2197
  residency: async () => {
2011
2198
  const sock = codexAppServerSock(runtimeDir)
2012
2199
  let pid = 0
@@ -2021,7 +2208,7 @@ export const codexHarness: Harness = {
2021
2208
  const result = await codexLoadedReferenceIds(sock)
2022
2209
  return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
2023
2210
  },
2024
- mutationGuard: (targetReferenceId) => codexTargetMutationGuard(targetReferenceId, runtimeDir),
2211
+ mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
2025
2212
  probe: () => codexSharedRuntimeProbe(runtimeDir),
2026
2213
  }],
2027
2214
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
@@ -7,7 +7,7 @@ import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type Ha
7
7
  import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
8
8
  import { repoRoot } from './git.js'
9
9
  import { endpointRecordPath } from './host.js'
10
- import { parseProcStat, processStartToken, processTopology, type ProcessIdentity } from './process-identity.js'
10
+ import { detachedRuntimeGenerationToken, parseProcStat, processStartToken, verifyDetachedRuntime, type ProcessIdentity } from './process-identity.js'
11
11
  import { readBackendInstanceRecords, type BackendInstanceRecord } from './runtime-ownership.js'
12
12
 
13
13
  type Proc = ProcessIdentity & {
@@ -384,6 +384,7 @@ const sessionStopBlocker = async (
384
384
  harnessId: string | null,
385
385
  recs = rawRecords(),
386
386
  knownProbes?: Map<string, SharedRuntimeProbe>,
387
+ opts: { coldReceipt?: unknown } = {},
387
388
  ): Promise<string | null> => {
388
389
  const allowed = harnessId
389
390
  ? new Set((harnessById(harnessId).sharedRuntimes?.(runtimeRoot()) ?? []).map((descriptor) => descriptor.key))
@@ -392,7 +393,6 @@ const sessionStopBlocker = async (
392
393
  if (allowed && !allowed.has(key)) continue
393
394
  const descriptor = entry.descriptor
394
395
  const pid = runtimePid(descriptor.pidFile)
395
- const startToken = pid ? processStartToken(pid) : null
396
396
  const ownerCounts = new Map<string, number>()
397
397
  for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
398
398
  const targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
@@ -401,24 +401,16 @@ const sessionStopBlocker = async (
401
401
  if (!knownProbes && descriptor.mutationGuard) {
402
402
  if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
403
403
  if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
404
- if (!startToken) return `${descriptor.label} PID ${pid} target-scoped mutation guard has no readable process-start identity`
405
- const topologyBefore = processTopology(pid)
406
- let stampBefore = ''
407
- try { stampBefore = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
408
- if (!topologyBefore || topologyBefore.startToken !== startToken || topologyBefore.processGroupId !== pid || topologyBefore.sessionId !== pid ||
409
- stampBefore !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`)
410
- return `${descriptor.label} PID ${pid}@${startToken} has no matching live detached process-boundary record`
404
+ const identityBefore = verifyDetachedRuntime(pid, descriptor.receiptFile)
405
+ if (!identityBefore.ok)
406
+ return `${descriptor.label} PID ${pid} has no matching live detached process-boundary record: ${identityBefore.reason}`
411
407
  let guard
412
- try { guard = await descriptor.mutationGuard(targetThread) }
408
+ try { guard = await descriptor.mutationGuard(targetThread, opts) }
413
409
  catch (error) { return `${descriptor.label} target-scoped mutation guard failed: ${(error as Error).message}` }
414
- const startAfter = processStartToken(pid)
415
- const topologyAfter = processTopology(pid)
416
- let stampAfter = ''
417
- try { stampAfter = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* changed/missing identity */ }
418
- if (startAfter !== startToken || !topologyAfter || topologyAfter.startToken !== startToken ||
419
- topologyAfter.processGroupId !== pid || topologyAfter.sessionId !== pid || stampAfter !== stampBefore)
420
- return `${descriptor.label} PID/start/isolation identity changed during target-scoped mutation guard`
421
- if (guard.descendantIds.length)
410
+ const identityAfter = verifyDetachedRuntime(pid, descriptor.receiptFile)
411
+ if (!identityAfter.ok || detachedRuntimeGenerationToken(identityAfter.identity) !== detachedRuntimeGenerationToken(identityBefore.identity))
412
+ return `${descriptor.label} PID/start/detached-receipt identity changed during target-scoped mutation guard${identityAfter.ok ? '' : `: ${identityAfter.reason}`}`
413
+ if (guard.descendantIds.length && guard.coldTeardownAuthorized !== true)
422
414
  return `${descriptor.label} target thread ${targetThread} has owned descendants (${guard.descendantIds.join(', ')})`
423
415
  if (!guard.healthy)
424
416
  return `${descriptor.label} target thread ${targetThread} is unknown: ${guard.error || 'target-scoped mutation guard failed'}`
@@ -433,17 +425,13 @@ const sessionStopBlocker = async (
433
425
  ? `${siblings.length} live sibling thread(s)`
434
426
  : probe.healthy ? `${probe.references.length} live thread reference(s)` : 'an unproven live reference set'
435
427
  if (!pid) return `${descriptor.label} has ${liveReason} but no readable owner PID`
436
- if (!startToken) return `${descriptor.label} PID ${pid} has ${liveReason} but no readable process-start identity`
437
- const topology = processTopology(pid)
438
- let stamp = ''
439
- try { stamp = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
440
- if (!topology || topology.startToken !== startToken || topology.processGroupId !== pid || topology.sessionId !== pid ||
441
- stamp !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`) {
428
+ const identity = verifyDetachedRuntime(pid, descriptor.receiptFile)
429
+ if (!identity.ok) {
442
430
  const refs = probe.references.map((reference) => reference.referenceId).join(', ') || 'no loaded threads'
443
- return `${descriptor.label} PID ${pid}@${startToken} serves ${refs} and has no matching live detached process-boundary record`
431
+ return `${descriptor.label} PID ${pid} serves ${refs} and has no matching live detached process-boundary record: ${identity.reason}`
444
432
  }
445
433
  if (!probe.healthy)
446
- return `${descriptor.label} PID ${pid}@${startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
434
+ return `${descriptor.label} PID ${pid}@${identity.identity.startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
447
435
  }
448
436
  return null
449
437
  }
@@ -658,9 +646,13 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
658
646
  return report
659
647
  }
660
648
 
661
- export async function assertSessionStopSafe(id: string, rec: (HarnessLivenessRecord & { harness?: string }) | null): Promise<void> {
649
+ export async function assertSessionStopSafe(
650
+ id: string,
651
+ rec: (HarnessLivenessRecord & { harness?: string }) | null,
652
+ opts: { coldReceipt?: unknown } = {},
653
+ ): Promise<void> {
662
654
  if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session record proves the adapter or leaf owner`)
663
- const blocker = await sessionStopBlocker(id, rec.harness || null)
655
+ const blocker = await sessionStopBlocker(id, rec.harness || null, rawRecords(), undefined, opts)
664
656
  if (blocker) throw new ResourceConflict(`refusing to stop ${id}: ${blocker}`)
665
657
  }
666
658
 
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
2
  import { createHash } from 'node:crypto'
3
- import { join, resolve, relative } from 'node:path'
3
+ import { join, resolve, relative, dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { execFileSync } from 'node:child_process'
6
6
  import { readConfig, readJsonConfig } from './layout.js'
@@ -81,6 +81,20 @@ function resolveHooksDir(dir: string): string | null {
81
81
  }
82
82
  }
83
83
 
84
+ // Detect once, at adoption, while the root checkout still names the branch being adopted. Steady-state
85
+ // layout never re-derives trunk identity from a mutable checkout (an ordinary `git switch node/x` must not
86
+ // turn that feature branch into trunk).
87
+ function adoptionMainBranch(dir: string): string {
88
+ try {
89
+ const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
90
+ const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
91
+ if (branch) return branch
92
+ } catch { /* render the one product-level repair below */ }
93
+ const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
94
+ error.name = 'ConfigError'
95
+ throw error
96
+ }
97
+
84
98
  export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
85
99
  const targetDir = resolve(targetArg ?? process.cwd())
86
100
 
@@ -153,18 +167,23 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
153
167
  const cfgDest = join(targetDir, 'spexcode.json')
154
168
  const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
155
169
  if (existsSync(cfgDest)) {
156
- if (flagRaw) {
157
- // an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
158
- const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
159
- cfg.harnesses = flagRaw
170
+ const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
171
+ const stampedBranch = typeof cfg.mainBranch !== 'string' || !cfg.mainBranch.trim()
172
+ if (stampedBranch) cfg.mainBranch = adoptionMainBranch(targetDir)
173
+ if (flagRaw) cfg.harnesses = flagRaw
174
+ if (flagRaw || stampedBranch) {
160
175
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
161
- console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
176
+ console.log(`✓ stamped ${[
177
+ flagRaw ? `"harnesses": ${JSON.stringify(flagRaw)}` : '',
178
+ stampedBranch ? `"mainBranch": ${JSON.stringify(cfg.mainBranch)}` : '',
179
+ ].filter(Boolean).join(' and ')} into the existing spexcode.json (other fields untouched)`)
162
180
  } else {
163
181
  console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
164
182
  }
165
183
  } else {
166
184
  const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
167
185
  cfg.harnesses = chosenHarnesses
186
+ cfg.mainBranch = adoptionMainBranch(targetDir)
168
187
  if (nativeChosen.length && cfg.sessions?.launchers) {
169
188
  cfg.sessions.launchers = Object.fromEntries(
170
189
  Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
@@ -173,7 +192,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
173
192
  }
174
193
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
175
194
  const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
176
- console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
195
+ console.log(`✓ planted spexcode.json — mainBranch ${JSON.stringify(cfg.mainBranch)}, harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
177
196
  }
178
197
 
179
198
  // 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
@@ -9,7 +9,7 @@ export { encodeProject, spexcodeHome } from './project-store.js'
9
9
 
10
10
  export type Config = {
11
11
  main?: string // path to the source-of-truth checkout (default: the `main` worktree)
12
- mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
12
+ mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
13
13
  branchPrefix?: string // how a branch names its node (default: "node/")
14
14
  preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
15
15
  // RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
@@ -115,16 +115,8 @@ export function gitCommonDir(): string {
115
115
 
116
116
  export function mainBranch(): string {
117
117
  let checkout: string
118
- try {
119
- checkout = mainCheckout()
120
- } catch { return 'main' }
121
- const override = readConfig(checkout).mainBranch?.trim()
122
- if (override) return override
123
- try {
124
- const cur = git(['-C', checkout, 'symbolic-ref', '--short', 'HEAD']).trim()
125
- if (cur) return cur
126
- } catch { /* detached/bare checkout: use the documented conventional default */ }
127
- return 'main'
118
+ try { checkout = mainCheckout() } catch { return 'main' }
119
+ return readConfig(checkout).mainBranch?.trim() || 'main'
128
120
  }
129
121
 
130
122
  // the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
@@ -1,10 +1,36 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { execFileSync } from 'node:child_process'
2
- import { readFileSync } from 'node:fs'
3
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
4
  import { platform } from 'node:os'
4
- import { join } from 'node:path'
5
+ import { dirname, join } from 'node:path'
5
6
 
6
7
  export type ProcessIdentity = { pid: number; startToken: string }
7
- export type ProcessTopology = ProcessIdentity & { processGroupId: number; sessionId: number }
8
+
9
+ export type ProcessAdapter = Readonly<{
10
+ platform: NodeJS.Platform
11
+ startToken(pid: number): string | null
12
+ processGroupId(pid: number): number | null
13
+ linuxSessionId(pid: number): number | null
14
+ }>
15
+
16
+ export type VerifiedDetachedRuntime = Readonly<ProcessIdentity & {
17
+ receiptVersion: 4
18
+ processGroupId: number
19
+ linuxSessionId?: number
20
+ }>
21
+
22
+ export type DetachedRuntimeVerification =
23
+ | { ok: true; identity: VerifiedDetachedRuntime }
24
+ | { ok: false; reason: string }
25
+
26
+ type DetachedLaunchReceiptV4 = {
27
+ version: 4
28
+ kind: 'spexcode-detached-runtime'
29
+ pid: number
30
+ startToken: string
31
+ processGroupId: number
32
+ linuxSessionId?: number
33
+ }
8
34
 
9
35
  export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
10
36
  const end = text.lastIndexOf(')')
@@ -21,22 +47,6 @@ export function parseProcStat(text: string): { ppid: number; processGroupId: num
21
47
  }
22
48
  }
23
49
 
24
- export function processTopology(pid: number, procRoot = '/proc'): ProcessTopology | null {
25
- if (platform() === 'linux' || procRoot !== '/proc') {
26
- try {
27
- const stat = parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8'))
28
- return { pid, startToken: stat.startToken, processGroupId: stat.processGroupId, sessionId: stat.sessionId }
29
- } catch { return null }
30
- }
31
- try {
32
- const [processGroupId, sessionId] = execFileSync('ps', ['-o', 'pgid=', '-o', 'sess=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/).map(Number)
33
- const startToken = processStartToken(pid, procRoot)
34
- return startToken && Number.isFinite(processGroupId) && Number.isFinite(sessionId)
35
- ? { pid, startToken, processGroupId, sessionId }
36
- : null
37
- } catch { return null }
38
- }
39
-
40
50
  export function processStartToken(pid: number, procRoot = '/proc'): string | null {
41
51
  if (platform() === 'linux' || procRoot !== '/proc') {
42
52
  try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
@@ -47,3 +57,118 @@ export function processStartToken(pid: number, procRoot = '/proc'): string | nul
47
57
  return started || null
48
58
  } catch { return null }
49
59
  }
60
+
61
+ const procField = (pid: number, field: 'processGroupId' | 'sessionId'): number | null => {
62
+ try {
63
+ const stat = parseProcStat(readFileSync(join('/proc', String(pid), 'stat'), 'utf8'))
64
+ return stat[field]
65
+ } catch { return null }
66
+ }
67
+
68
+ const psProcessGroupId = (pid: number): number | null => {
69
+ try {
70
+ const value = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim())
71
+ return Number.isInteger(value) && value > 0 ? value : null
72
+ } catch { return null }
73
+ }
74
+
75
+ export const hostProcessAdapter: ProcessAdapter = Object.freeze({
76
+ platform: platform(),
77
+ startToken: (pid) => processStartToken(pid),
78
+ processGroupId: (pid) => platform() === 'linux' ? procField(pid, 'processGroupId') : psProcessGroupId(pid),
79
+ linuxSessionId: (pid) => platform() === 'linux' ? procField(pid, 'sessionId') : null,
80
+ })
81
+
82
+ const exactKeys = (value: Record<string, unknown>, expected: string[]) => {
83
+ const actual = Object.keys(value).sort()
84
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index])
85
+ }
86
+
87
+ const parseDetachedLaunchReceipt = (text: string, hostPlatform: NodeJS.Platform): DetachedLaunchReceiptV4 | null => {
88
+ let value: unknown
89
+ try { value = JSON.parse(text) } catch { return null }
90
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
91
+ const receipt = value as Record<string, unknown>
92
+ const keys = ['kind', 'pid', 'processGroupId', 'startToken', 'version', ...(hostPlatform === 'linux' ? ['linuxSessionId'] : [])].sort()
93
+ if (!exactKeys(receipt, keys) || receipt.version !== 4 || receipt.kind !== 'spexcode-detached-runtime' ||
94
+ !Number.isInteger(receipt.pid) || Number(receipt.pid) <= 0 || typeof receipt.startToken !== 'string' || !receipt.startToken ||
95
+ !Number.isInteger(receipt.processGroupId) || Number(receipt.processGroupId) <= 0 ||
96
+ (hostPlatform === 'linux' && (!Number.isInteger(receipt.linuxSessionId) || Number(receipt.linuxSessionId) <= 0))) return null
97
+ return receipt as DetachedLaunchReceiptV4
98
+ }
99
+
100
+ const observeDetachedRuntime = (pid: number, adapter: ProcessAdapter): DetachedRuntimeVerification => {
101
+ if (adapter.platform === 'win32') return { ok: false, reason: 'detached runtime identity is unsupported on win32' }
102
+ const startBefore = adapter.startToken(pid)
103
+ if (!startBefore) return { ok: false, reason: `PID ${pid} has no readable process-start identity` }
104
+ const processGroupId = adapter.processGroupId(pid)
105
+ if (processGroupId !== pid) return { ok: false, reason: `PID ${pid} is not its own process-group leader (pgrp=${processGroupId ?? 'unknown'})` }
106
+ let linuxSessionId: number | undefined
107
+ if (adapter.platform === 'linux') {
108
+ const sessionId = adapter.linuxSessionId(pid)
109
+ if (sessionId !== pid) return { ok: false, reason: `Linux PID ${pid} is not its own session leader (session=${sessionId ?? 'unknown'})` }
110
+ linuxSessionId = sessionId
111
+ }
112
+ const startAfter = adapter.startToken(pid)
113
+ if (!startAfter || startAfter !== startBefore)
114
+ return { ok: false, reason: `PID ${pid} process-start identity changed during detached-boundary verification` }
115
+ return {
116
+ ok: true,
117
+ identity: Object.freeze({
118
+ pid,
119
+ startToken: startAfter,
120
+ receiptVersion: 4,
121
+ processGroupId,
122
+ ...(linuxSessionId === undefined ? {} : { linuxSessionId }),
123
+ }),
124
+ }
125
+ }
126
+
127
+ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): DetachedRuntimeVerification {
128
+ let text: string
129
+ try { text = readFileSync(receiptFile, 'utf8') }
130
+ catch { return { ok: false, reason: 'detached launch receipt is missing or unreadable' } }
131
+ const receipt = parseDetachedLaunchReceipt(text, adapter.platform)
132
+ if (!receipt) return { ok: false, reason: 'detached launch receipt has the wrong version or shape' }
133
+ if (receipt.pid !== pid) return { ok: false, reason: `detached launch receipt names PID ${receipt.pid}, not ${pid}` }
134
+ if (receipt.processGroupId !== pid)
135
+ return { ok: false, reason: `detached launch receipt has wrong process group ${receipt.processGroupId} for PID ${pid}` }
136
+ if (adapter.platform === 'linux' && receipt.linuxSessionId !== pid)
137
+ return { ok: false, reason: `detached launch receipt has wrong Linux session ${receipt.linuxSessionId ?? 'missing'} for PID ${pid}` }
138
+ const live = observeDetachedRuntime(pid, adapter)
139
+ if (!live.ok) return live
140
+ if (live.identity.startToken !== receipt.startToken)
141
+ return { ok: false, reason: `PID ${pid} process-start identity does not match its detached launch receipt` }
142
+ return live
143
+ }
144
+
145
+ export function writeDetachedRuntimeReceipt(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): VerifiedDetachedRuntime {
146
+ const observed = observeDetachedRuntime(pid, adapter)
147
+ if (!observed.ok) throw new Error(`cannot prove detached shared runtime: ${observed.reason}`)
148
+ const receipt: DetachedLaunchReceiptV4 = {
149
+ version: 4,
150
+ kind: 'spexcode-detached-runtime',
151
+ pid,
152
+ startToken: observed.identity.startToken,
153
+ processGroupId: observed.identity.processGroupId,
154
+ ...(observed.identity.linuxSessionId === undefined ? {} : { linuxSessionId: observed.identity.linuxSessionId }),
155
+ }
156
+ mkdirSync(dirname(receiptFile), { recursive: true })
157
+ const tmp = `${receiptFile}.${process.pid}.${randomUUID()}.tmp`
158
+ let published = false
159
+ try {
160
+ writeFileSync(tmp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 })
161
+ renameSync(tmp, receiptFile)
162
+ published = true
163
+ const verified = verifyDetachedRuntime(pid, receiptFile, adapter)
164
+ if (!verified.ok) throw new Error(`cannot verify detached shared runtime receipt: ${verified.reason}`)
165
+ return verified.identity
166
+ } catch (error) {
167
+ rmSync(tmp, { force: true })
168
+ if (published) rmSync(receiptFile, { force: true })
169
+ throw error
170
+ }
171
+ }
172
+
173
+ export const detachedRuntimeGenerationToken = (identity: VerifiedDetachedRuntime) =>
174
+ `detached-v4|${identity.pid}|${identity.startToken}|${identity.processGroupId}|${identity.linuxSessionId ?? '-'}`
@@ -4,7 +4,7 @@ import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeF
4
4
  import { dirname, join } from 'node:path'
5
5
  import { repoRoot } from './git.js'
6
6
  import { readJsonConfig, runtimeRoot } from './layout.js'
7
- import { processStartToken, processTopology, type ProcessIdentity } from './process-identity.js'
7
+ import { processStartToken, writeDetachedRuntimeReceipt, type ProcessIdentity } from './process-identity.js'
8
8
 
9
9
  export type BackendInstanceRecord = {
10
10
  version: 1
@@ -51,20 +51,11 @@ export function unregisterBackendInstance(instanceId: string, pid = process.pid)
51
51
  } catch { /* not ours / already removed */ }
52
52
  }
53
53
 
54
- export function writeIsolationStamp(pid: number, file: string): void {
55
- const topology = processTopology(pid)
56
- if (!topology) throw new Error(`cannot identify shared runtime PID ${pid}`)
57
- if (topology.processGroupId !== pid || topology.sessionId !== pid)
58
- throw new Error(`shared runtime PID ${pid} is not detached from its launch session (pgrp=${topology.processGroupId}, session=${topology.sessionId})`)
59
- mkdirSync(dirname(file), { recursive: true })
60
- writeFileSync(file, `detached-v3 ${pid} ${topology.startToken} ${topology.processGroupId} ${topology.sessionId}\n`, { mode: 0o600 })
61
- }
62
-
63
54
  export function spawnDetachedRuntime(opts: {
64
55
  cwd: string
65
56
  logFile: string
66
57
  pidFile: string
67
- isolationFile: string
58
+ receiptFile: string
68
59
  command: string
69
60
  args: string[]
70
61
  env?: NodeJS.ProcessEnv
@@ -85,15 +76,13 @@ export function spawnDetachedRuntime(opts: {
85
76
  if (!child.pid) throw new Error(`could not spawn detached shared runtime: ${opts.command}`)
86
77
  child.unref()
87
78
  try {
88
- writeIsolationStamp(child.pid, opts.isolationFile)
89
- const startToken = processStartToken(child.pid)
90
- if (!startToken) throw new Error(`cannot identify detached shared runtime PID ${child.pid}`)
79
+ const identity = writeDetachedRuntimeReceipt(child.pid, opts.receiptFile)
91
80
  writeFileSync(opts.pidFile, `${child.pid}\n`, { mode: 0o600 })
92
- return { pid: child.pid, startToken }
81
+ return { pid: identity.pid, startToken: identity.startToken }
93
82
  } catch (error) {
94
83
  try { child.kill('SIGTERM') } catch { /* exact just-spawned child already exited */ }
95
84
  rmSync(opts.pidFile, { force: true })
96
- rmSync(opts.isolationFile, { force: true })
85
+ rmSync(opts.receiptFile, { force: true })
97
86
  throw error
98
87
  }
99
88
  }
@@ -2437,10 +2437,11 @@ async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIde
2437
2437
  return { pid, startToken, ownerNeedle }
2438
2438
  }
2439
2439
 
2440
- async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false): Promise<void> {
2440
+ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
2441
2441
  // The caller resolves one readable owner before entering this seam. An absent/corrupt record never reaches
2442
2442
  // tmux, signals, or adapter cleanup: a bare session id is an address, not ownership authority.
2443
- const assertOwned = () => assertSessionStopSafe(id, rec ? { ...rec, harness: rec.harness } : null)
2443
+ const assertOwned = () => assertSessionStopSafe(id, rec ? { ...rec, harness: rec.harness } : null,
2444
+ { ...(requireCold && coldReceipt !== undefined ? { coldReceipt } : {}) })
2444
2445
  await assertOwned()
2445
2446
  if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session owner`)
2446
2447
  const harness = harnessById(rec.harness || defaultHarness.id)
@@ -2453,7 +2454,7 @@ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = f
2453
2454
  launchedAt.delete(id)
2454
2455
  await harness.cleanupRuntime(rec)
2455
2456
  if (requireCold) {
2456
- const cold = await harness.coldRuntime?.(rec)
2457
+ const cold = await harness.coldRuntime?.(rec, coldReceipt)
2457
2458
  if (cold && !cold.ok) throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`)
2458
2459
  }
2459
2460
  }
@@ -2521,7 +2522,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2521
2522
  if (rootAbsent) return true
2522
2523
  const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
2523
2524
  if (!pre || pre.ok) {
2524
- const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true })
2525
+ const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined)
2525
2526
  if (!cold || cold.ok) return true
2526
2527
  }
2527
2528
  }
@@ -2541,8 +2542,8 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2541
2542
  : liveness({ ...wt.rec, archived: false, stopped: false }, snap)
2542
2543
  if (lv === 'unknown' || lv === 'starting')
2543
2544
  throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
2544
- // The adapter guard runs BEFORE any tmux/process signal. Active/unknown Codex turns and owned descendants
2545
- // refuse here, leaving the leaf/shared root/record untouched; coldRuntime is the commit cleanup after this.
2545
+ // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
2546
+ // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
2546
2547
  const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
2547
2548
  if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
2548
2549
  // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
@@ -2552,7 +2553,8 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2552
2553
  let coldAttempted = false
2553
2554
  try {
2554
2555
  coldAttempted = true
2555
- await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true)
2556
+ await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true,
2557
+ preflight?.ok ? preflight.receipt : undefined)
2556
2558
  coldCommitted = true
2557
2559
  const latest = readRecord(id)
2558
2560
  if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
@@ -2566,7 +2568,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
2566
2568
  writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
2567
2569
  } catch (error) {
2568
2570
  if (coldCommitted) {
2569
- const restored = await h.restoreRuntime?.(wt.rec)
2571
+ const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined)
2570
2572
  if (restored && !restored.ok) {
2571
2573
  const current = readRecord(id)
2572
2574
  if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: null, adapterRecovery: `restore-runtime:${restored.reason}` })
@@ -58,18 +58,12 @@ fi
58
58
  # stamp already passed above. Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
59
59
  #
60
60
  # The trunk is resolved the SAME way the rest of SpexCode resolves it — `spex internal trunk` = layout.ts
61
- # mainBranch() (config override the main checkout's current branch 'main') — so the guard protects
62
- # whatever the repo's trunk is actually named, NOT a hardcoded 'main' (which gave a `master`/non-`main`
63
- # trunk zero protection). When the CLI isn't resolvable (advisory mode), fall back to a pure-git
64
- # auto-detect of the main checkout's current branch, then 'main' — still protecting a non-`main` trunk.
61
+ # mainBranch() (the stable mainBranch project fact stamped at adoption; conventional default 'main') — so
62
+ # the guard protects whatever the repo's trunk is actually named. When the CLI isn't resolvable (advisory
63
+ # mode), only the convention is available: shell does not guess identity from a mutable checkout.
65
64
  branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo)
66
65
  git_dir=$(git rev-parse --git-dir 2>/dev/null)
67
66
  trunk=$(spex_cli internal trunk 2>/dev/null | head -1)
68
- # Fallback auto-detect: the main checkout's current branch. Strip the hook's exported GIT_DIR/
69
- # GIT_INDEX_FILE first — otherwise they pin discovery to the COMMITTING worktree and `-C "$main_root"`
70
- # is ignored, so it would read THIS branch and think every node branch is the trunk. (`spex internal
71
- # trunk` above is immune: layout.ts mainBranch() resolves through git.ts's env-stripping git().)
72
- [ -n "$trunk" ] || trunk=$(env -u GIT_DIR -u GIT_INDEX_FILE git -C "$main_root" symbolic-ref --short HEAD 2>/dev/null)
73
67
  [ -n "$trunk" ] || trunk=main
74
68
  if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
75
69
  echo "✗ SpexCode: direct commits on $trunk (the trunk) are blocked." >&2