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 +1 -1
- package/spec-cli/src/cli.ts +4 -4
- package/spec-cli/src/guide.ts +2 -1
- package/spec-cli/src/harness.ts +299 -112
- package/spec-cli/src/host-resources.ts +20 -28
- package/spec-cli/src/init.ts +26 -7
- package/spec-cli/src/layout.ts +3 -11
- package/spec-cli/src/process-identity.ts +144 -19
- package/spec-cli/src/runtime-ownership.ts +5 -16
- package/spec-cli/src/sessions.ts +10 -8
- package/spec-cli/templates/hooks/pre-commit +3 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
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",
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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,
|
|
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 || !
|
|
1012
|
-
console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <
|
|
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,
|
|
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 }
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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.
|
|
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" }
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
432
|
+
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
|
|
426
433
|
type CodexRuntimeGenerationProof = Readonly<{
|
|
427
|
-
|
|
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
|
|
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) || !
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
`
|
|
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
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$
|
|
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(
|
|
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
|
-
|
|
1033
|
-
|
|
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 [
|
|
1036
|
-
|
|
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 (
|
|
1041
|
-
return { ok: false, reason:
|
|
1042
|
-
if (!
|
|
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
|
-
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
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
|
|
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 (
|
|
1910
|
-
if (!
|
|
1911
|
-
|
|
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
|
-
|
|
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
|
|
1928
|
-
const
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
if (
|
|
1934
|
-
|
|
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
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
if (
|
|
1942
|
-
|
|
1943
|
-
if (!
|
|
1944
|
-
|
|
1945
|
-
|
|
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
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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}
|
|
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(
|
|
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
|
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
85
|
+
rmSync(opts.receiptFile, { force: true })
|
|
97
86
|
throw error
|
|
98
87
|
}
|
|
99
88
|
}
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -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
|
|
2545
|
-
// refuse here
|
|
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() (
|
|
62
|
-
# whatever the repo's trunk is actually named
|
|
63
|
-
#
|
|
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
|