spexcode 0.6.1 → 0.6.2
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/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/harness.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { join, dirname } from 'node:path'
|
|
2
|
+
import { join, dirname, basename } from 'node:path'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import { createConnection, type Socket } from 'node:net'
|
|
@@ -19,6 +19,7 @@ import { shQuote } from './sh.js'
|
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
|
|
21
21
|
import { writeFileIfChanged } from './file-write.js'
|
|
22
|
+
import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace, type ExecutionTrace, type ExecutionTurn } from './execution-trace.js'
|
|
22
23
|
|
|
23
24
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
24
25
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -32,7 +33,7 @@ import { writeFileIfChanged } from './file-write.js'
|
|
|
32
33
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
33
34
|
// (materialize writes every harness's artifacts).
|
|
34
35
|
|
|
35
|
-
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
36
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
36
37
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
|
|
37
38
|
export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
|
|
38
39
|
export type HarnessLaunchReadinessFence = {
|
|
@@ -149,7 +150,7 @@ export async function adapterLoadedReferenceState(
|
|
|
149
150
|
export interface Harness {
|
|
150
151
|
readonly id: HarnessId
|
|
151
152
|
// the id baked into the materialized shim. Headless variants reuse their native family's shim.
|
|
152
|
-
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
153
|
+
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi' | 'zcode'
|
|
153
154
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
154
155
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
155
156
|
readonly headless: boolean
|
|
@@ -183,6 +184,9 @@ export interface Harness {
|
|
|
183
184
|
// instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
|
|
184
185
|
// is data on the adapter, not an `if (codex)` in sessions.ts.
|
|
185
186
|
readonly paneTitleIsSelfSummary: boolean
|
|
187
|
+
// The adapter-only native transcript reader. Its compact result has no raw envelope, argument, output, or
|
|
188
|
+
// reasoning data; product surfaces receive only the latest working note and typed tool steps.
|
|
189
|
+
executionTrace(threadId: string, turn: ExecutionTurn | null): ExecutionTrace | null
|
|
186
190
|
// --- launch / sessionId ---
|
|
187
191
|
// the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
|
|
188
192
|
// visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
|
|
@@ -523,7 +527,62 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
523
527
|
|
|
524
528
|
// The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
|
|
525
529
|
// carrying the timeline mid and reports only whether that write reached the local transport.
|
|
526
|
-
|
|
530
|
+
type ClaudeForkTransport = { sock: string; auth: string }
|
|
531
|
+
|
|
532
|
+
// The backend need not share the agent's config root: an explicitly chosen launcher can point Claude at its
|
|
533
|
+
// own home. A moved source process remains alive by definition, so its one config-dir environment field is the
|
|
534
|
+
// live authority for locating the daemon roster. A stale/reused pid can at worst name a roster with no exact
|
|
535
|
+
// successor; it cannot select one without the moved/session-source checks below.
|
|
536
|
+
function claudeConfigRoots(sourceSessionId: string, runtimeDir?: string): string[] {
|
|
537
|
+
const roots: string[] = []
|
|
538
|
+
try {
|
|
539
|
+
const pidFile = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'agent.pid') : sessionArtifactPath(sourceSessionId, 'agent.pid')
|
|
540
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
|
541
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
542
|
+
const env = readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')
|
|
543
|
+
const config = env.find((entry) => entry.startsWith('CLAUDE_CONFIG_DIR='))?.slice('CLAUDE_CONFIG_DIR='.length)
|
|
544
|
+
if (config) roots.push(config)
|
|
545
|
+
}
|
|
546
|
+
} catch { /* source not available or this platform does not expose procfs */ }
|
|
547
|
+
if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
|
|
548
|
+
roots.push(join(homedir(), '.claude'))
|
|
549
|
+
return [...new Set(roots)]
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// A moved Claude conversation is a daemon-owned fork. Its launch-time socket still answers, but that process
|
|
553
|
+
// no longer returns to its prompt. A successor hook may have already recorded its exact Claude session id in
|
|
554
|
+
// `moved`; use that durable identity first, then retain the roster's source-transcript relation for deployments
|
|
555
|
+
// without the stamp. The roster remains the sole source of the live socket and current auth token. Keep this
|
|
556
|
+
// lookup Claude-local so other rendezvous adapters cannot adopt a coincidentally matching id.
|
|
557
|
+
function claudeForkTransport(sourceSessionId: string, runtimeDir?: string): ClaudeForkTransport | null {
|
|
558
|
+
const moved = (() => {
|
|
559
|
+
try {
|
|
560
|
+
const stamp = runtimeDir ? join(runtimeDir, 'sessions', sourceSessionId, 'moved') : sessionArtifactPath(sourceSessionId, 'moved')
|
|
561
|
+
return readFileSync(stamp, 'utf8').trim()
|
|
562
|
+
} catch {
|
|
563
|
+
return ''
|
|
564
|
+
}
|
|
565
|
+
})()
|
|
566
|
+
for (const configDir of claudeConfigRoots(sourceSessionId, runtimeDir)) {
|
|
567
|
+
try {
|
|
568
|
+
const roster = JSON.parse(readFileSync(join(configDir, 'daemon', 'roster.json'), 'utf8')) as { workers?: Record<string, any> }
|
|
569
|
+
const workers = Object.values(roster.workers ?? {})
|
|
570
|
+
const usable = (worker: any) => typeof worker?.rendezvousSock === 'string' && typeof worker.rvAuth === 'string'
|
|
571
|
+
const recorded = workers.filter((worker) => usable(worker) && moved && worker?.sessionId === moved)
|
|
572
|
+
const candidates = recorded.length ? recorded : workers.filter((worker) => {
|
|
573
|
+
const launch = worker?.dispatch?.launch
|
|
574
|
+
if (launch?.mode !== 'resume' || launch.fork !== true || typeof launch.sessionId !== 'string') return false
|
|
575
|
+
const source = basename(launch.sessionId).replace(/\.jsonl$/, '')
|
|
576
|
+
return source === sourceSessionId && usable(worker)
|
|
577
|
+
})
|
|
578
|
+
const worker = candidates.sort((a, b) => Number(b.startedAt ?? 0) - Number(a.startedAt ?? 0))[0]
|
|
579
|
+
if (worker) return { sock: worker.rendezvousSock, auth: worker.rvAuth }
|
|
580
|
+
} catch { /* this Claude config has no readable daemon roster */ }
|
|
581
|
+
}
|
|
582
|
+
return null
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function replyViaSocket(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
|
|
527
586
|
return new Promise((resolve) => {
|
|
528
587
|
let settled = false
|
|
529
588
|
let c: ReturnType<typeof createConnection>
|
|
@@ -545,7 +604,7 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
|
|
|
545
604
|
})
|
|
546
605
|
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
|
|
547
606
|
c.on('connect', () => c.write(
|
|
548
|
-
JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })
|
|
607
|
+
`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`,
|
|
549
608
|
(error) => {
|
|
550
609
|
if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
|
|
551
610
|
c.end()
|
|
@@ -555,16 +614,32 @@ function replyViaSocket(sock: string, text: string, mid?: string): Promise<Dispa
|
|
|
555
614
|
})
|
|
556
615
|
}
|
|
557
616
|
const POKE_ATTEMPTS = 2
|
|
558
|
-
|
|
559
|
-
const sock = rvSock(id)
|
|
617
|
+
async function pokeRendezvous(sock: string, text: string, mid?: string, auth?: string): Promise<DispatchResult> {
|
|
560
618
|
let last: DispatchResult = { ok: false, error: 'not attempted' }
|
|
561
619
|
for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
|
|
562
|
-
last = await replyViaSocket(sock, text, mid)
|
|
620
|
+
last = await replyViaSocket(sock, text, mid, auth)
|
|
563
621
|
if (last.ok) return last
|
|
564
622
|
}
|
|
565
623
|
return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
|
|
566
624
|
}
|
|
567
625
|
|
|
626
|
+
export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
|
|
627
|
+
return pokeRendezvous(rvSock(id), text, mid)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function deliverViaClaudeRendezvous(id: string, text: string, mid?: string, runtimeDir?: string): Promise<DispatchResult> {
|
|
631
|
+
const fork = claudeForkTransport(id, runtimeDir)
|
|
632
|
+
const sourceSock = rvSock(id)
|
|
633
|
+
if (!fork) return pokeRendezvous(sourceSock, text, mid)
|
|
634
|
+
|
|
635
|
+
const forkResult = await pokeRendezvous(fork.sock, text, mid, fork.auth)
|
|
636
|
+
if (forkResult.ok || fork.sock === sourceSock) return forkResult
|
|
637
|
+
|
|
638
|
+
const sourceResult = await pokeRendezvous(sourceSock, text, mid)
|
|
639
|
+
if (sourceResult.ok) return sourceResult
|
|
640
|
+
return { ok: false, error: `fork rendezvous failed: ${forkResult.error}; source fallback failed: ${sourceResult.error}` }
|
|
641
|
+
}
|
|
642
|
+
|
|
568
643
|
export async function deliverViaSocketOrWake(
|
|
569
644
|
id: string,
|
|
570
645
|
text: string,
|
|
@@ -905,50 +980,61 @@ export function codexTurnFailureObserver(
|
|
|
905
980
|
return { close: () => finish(null), closed }
|
|
906
981
|
}
|
|
907
982
|
|
|
908
|
-
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
909
|
-
// defines thread/archive and thread/unarchive with {threadId}
|
|
983
|
+
// Protocol-verified cold/restore/control seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
984
|
+
// defines thread/archive and thread/unarchive with {threadId}, plus turn/interrupt with {threadId, turnId}; no
|
|
985
|
+
// guessed method or process command is used.
|
|
910
986
|
type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
|
|
911
|
-
|
|
987
|
+
// A failed mutation says whether the server can still commit it. `refused` means the request never reached the
|
|
988
|
+
// server or the server answered by rejecting it, so the target is provably unchanged and compensation is safe.
|
|
989
|
+
// `unknown` means the request was sent and no verdict came back — the server may still be executing it, so
|
|
990
|
+
// sending anything else down the same connection queues behind that work and fails too.
|
|
991
|
+
type CodexMutationOutcome = { ok: true } | { ok: false; error: string; commit: 'refused' | 'unknown' }
|
|
992
|
+
function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive' | 'turn/interrupt', threadId: string, fence?: CodexGenerationFence, turnId?: string, budgetMs = CODEX_MUTATION_BASE_MS): Promise<CodexMutationOutcome> {
|
|
912
993
|
const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
|
|
913
994
|
? `Codex ${method} refused because the shared app-server generation changed`
|
|
914
995
|
: null
|
|
915
996
|
const before = generationError()
|
|
916
|
-
if (before) return Promise.resolve({ ok: false, error: before })
|
|
997
|
+
if (before) return Promise.resolve({ ok: false, error: before, commit: 'refused' })
|
|
998
|
+
if (method === 'turn/interrupt' && !turnId)
|
|
999
|
+
return Promise.resolve({ ok: false, error: 'Codex turn interrupt needs an exact turn id', commit: 'refused' })
|
|
917
1000
|
return new Promise((resolve) => {
|
|
918
1001
|
const conn: Socket = createConnection(sock)
|
|
919
1002
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
920
|
-
let upgraded = false, settled = false
|
|
921
|
-
const done = (r:
|
|
1003
|
+
let upgraded = false, settled = false, requested = false
|
|
1004
|
+
const done = (r: CodexMutationOutcome) => {
|
|
922
1005
|
if (settled) return
|
|
923
1006
|
settled = true
|
|
924
1007
|
clearTimeout(timer)
|
|
925
1008
|
try { conn.destroy() } catch {}
|
|
926
1009
|
resolve(r)
|
|
927
1010
|
}
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
conn.on('
|
|
1011
|
+
// Once the request is on the wire the server owns it, so every unanswered end — timeout, socket error,
|
|
1012
|
+
// early close, a generation swap — leaves the commit unknown rather than refused.
|
|
1013
|
+
const unanswered = (error: string) => done({ ok: false, error, commit: requested ? 'unknown' : 'refused' })
|
|
1014
|
+
const timer = setTimeout(() => unanswered(generationError() || `Codex ${method} did not answer within ${budgetMs}ms`), budgetMs)
|
|
1015
|
+
conn.on('error', (e) => unanswered(generationError() || `Codex ${method} connection failed: ${rpcError(e)}`))
|
|
1016
|
+
conn.on('close', () => { if (!settled) unanswered(`Codex app-server closed during ${method}`) })
|
|
933
1017
|
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
934
1018
|
conn.on('connect', () => {
|
|
935
1019
|
const changed = generationError()
|
|
936
|
-
if (changed) return
|
|
1020
|
+
if (changed) return unanswered(changed)
|
|
937
1021
|
conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
|
|
938
1022
|
})
|
|
939
1023
|
const handle = (json: string) => {
|
|
940
1024
|
let m: JsonRpc
|
|
941
1025
|
try { m = JSON.parse(json) } catch { return }
|
|
942
|
-
|
|
1026
|
+
// The server answered by rejecting, so the target is provably unchanged whether or not we had sent it.
|
|
1027
|
+
if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}`, commit: 'refused' })
|
|
943
1028
|
if (m.id === 1 && m.result) {
|
|
944
1029
|
const changed = generationError()
|
|
945
|
-
if (changed) return
|
|
1030
|
+
if (changed) return unanswered(changed)
|
|
946
1031
|
send({ method: 'initialized', params: {} })
|
|
947
|
-
|
|
1032
|
+
requested = true
|
|
1033
|
+
return send({ id: 2, method, params: method === 'turn/interrupt' ? { threadId, turnId } : { threadId } })
|
|
948
1034
|
}
|
|
949
1035
|
if (m.id === 2 && m.result) {
|
|
950
1036
|
const changed = generationError()
|
|
951
|
-
return changed ?
|
|
1037
|
+
return changed ? unanswered(changed) : done({ ok: true })
|
|
952
1038
|
}
|
|
953
1039
|
}
|
|
954
1040
|
conn.on('data', (chunk: Buffer) => {
|
|
@@ -957,17 +1043,32 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
|
|
|
957
1043
|
const i = fs.buf.indexOf('\r\n\r\n')
|
|
958
1044
|
if (i < 0) return
|
|
959
1045
|
const head = fs.buf.slice(0, i).toString('utf8')
|
|
960
|
-
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}
|
|
1046
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}`, commit: 'refused' })
|
|
961
1047
|
upgraded = true
|
|
962
1048
|
fs.buf = fs.buf.slice(i + 4)
|
|
963
1049
|
send(wsInitialize)
|
|
964
1050
|
}
|
|
965
|
-
if (drainWsFrames(fs, conn, handle))
|
|
1051
|
+
if (drainWsFrames(fs, conn, handle)) unanswered(`Codex app-server closed during ${method}`)
|
|
966
1052
|
})
|
|
967
1053
|
})
|
|
968
1054
|
}
|
|
969
1055
|
|
|
970
1056
|
type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
|
|
1057
|
+
// Dashboard/resource probes keep their own short budget; this target-scoped census is only entered by a
|
|
1058
|
+
// lifecycle mutation that already holds the session transition lock and must tolerate a busy app-server.
|
|
1059
|
+
const CODEX_MUTATION_CENSUS_MS = 15_000
|
|
1060
|
+
// A mutation's response budget. `thread/unarchive` and `turn/interrupt` are state flips the server answers at
|
|
1061
|
+
// once — measured 36ms to unarchive the very same 279 MB thread that took 47.7s to archive — so they keep the
|
|
1062
|
+
// base. `thread/archive` on a LOADED thread differs in kind: the server flushes that thread's whole in-memory
|
|
1063
|
+
// rollout inside shutdown_and_wait before it commits, so the wait is proportional to accumulated history
|
|
1064
|
+
// (measured 47.7s for 279 MB, ~5.9 MB/s, against ~1.5s for a notLoaded member that flushes nothing). A fixed
|
|
1065
|
+
// ceiling therefore never bounds the operation; it only picks the transcript size above which archive stops
|
|
1066
|
+
// working, and raising it just moves that size. The scaled term is deliberately pessimistic — a floor rate ~6x
|
|
1067
|
+
// under the measured one — because its job is to catch a WEDGED server, not to predict a flush: a machine
|
|
1068
|
+
// several times slower still archives, while a hung one still fails loudly.
|
|
1069
|
+
const CODEX_MUTATION_BASE_MS = 15_000
|
|
1070
|
+
const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000
|
|
1071
|
+
const codexArchiveBudgetMs = (bytes: number) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS)
|
|
971
1072
|
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
972
1073
|
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
973
1074
|
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
@@ -982,6 +1083,7 @@ function codexPagedIds(
|
|
|
982
1083
|
extractId: (item: unknown) => string | null,
|
|
983
1084
|
label: string,
|
|
984
1085
|
onItem?: (item: unknown) => void,
|
|
1086
|
+
timeoutMs = CODEX_MUTATION_CENSUS_MS,
|
|
985
1087
|
): Promise<CodexPagedIdsResult> {
|
|
986
1088
|
return new Promise((resolve) => {
|
|
987
1089
|
const conn: Socket = createConnection(sock)
|
|
@@ -992,7 +1094,7 @@ function codexPagedIds(
|
|
|
992
1094
|
if (settled) return
|
|
993
1095
|
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
994
1096
|
}
|
|
995
|
-
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }),
|
|
1097
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out after ${timeoutMs}ms` }), timeoutMs)
|
|
996
1098
|
conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
|
|
997
1099
|
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
|
|
998
1100
|
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
@@ -1041,18 +1143,23 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
|
|
|
1041
1143
|
return result.ok ? { ok: true, referenceIds: result.ids } : result
|
|
1042
1144
|
}
|
|
1043
1145
|
|
|
1044
|
-
const
|
|
1146
|
+
const CODEX_RUNNING_TURN_READ_MS = 15_000
|
|
1045
1147
|
|
|
1046
|
-
|
|
1148
|
+
// @@@ presence vs identity - two different questions, deliberately not one helper.
|
|
1149
|
+
// A gate asks "is a turn in flight right now"; thread/list answers that for every thread at once, at a cost
|
|
1150
|
+
// that tracks the thread COUNT. Interrupt must additionally name the turn to interrupt, and only a turn read
|
|
1151
|
+
// carries the id — a cost that tracks that one thread's persisted HISTORY. So this read stays for interrupt,
|
|
1152
|
+
// where the target is by definition active and short-lived, and no gate may be routed back through it.
|
|
1153
|
+
function codexRunningTurn(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }> {
|
|
1047
1154
|
return new Promise((resolve) => {
|
|
1048
1155
|
const conn: Socket = createConnection(sock)
|
|
1049
1156
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
1050
1157
|
let upgraded = false, settled = false
|
|
1051
|
-
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
|
|
1158
|
+
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown'; turnId?: string } | { ok: false; error: string }) => {
|
|
1052
1159
|
if (settled) return
|
|
1053
1160
|
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
1054
1161
|
}
|
|
1055
|
-
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${
|
|
1162
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_RUNNING_TURN_READ_MS}ms` }), CODEX_RUNNING_TURN_READ_MS)
|
|
1056
1163
|
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
|
|
1057
1164
|
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
|
|
1058
1165
|
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
@@ -1070,7 +1177,12 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1070
1177
|
if (message.id !== 2 || !message.result) return
|
|
1071
1178
|
const turns = (message.result as { data?: unknown }).data
|
|
1072
1179
|
if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
|
|
1073
|
-
|
|
1180
|
+
const active = turns.find((turn): turn is { id?: unknown; status?: unknown } =>
|
|
1181
|
+
!!turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress')
|
|
1182
|
+
if (!active) return done({ ok: true, turnPresence: 'idle' })
|
|
1183
|
+
return typeof active.id === 'string' && active.id
|
|
1184
|
+
? done({ ok: true, turnPresence: 'active', turnId: active.id })
|
|
1185
|
+
: done({ ok: true, turnPresence: 'unknown' })
|
|
1074
1186
|
}
|
|
1075
1187
|
conn.on('data', (chunk: Buffer) => {
|
|
1076
1188
|
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
@@ -1086,14 +1198,57 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1086
1198
|
})
|
|
1087
1199
|
}
|
|
1088
1200
|
|
|
1201
|
+
const CODEX_INTERRUPT_SETTLE_MS = 15_000
|
|
1202
|
+
|
|
1203
|
+
async function interruptCodexTurn(rec: HarnessDeliveryRecord): Promise<DispatchResult> {
|
|
1204
|
+
if (!rec.harnessSessionId) return { ok: false, error: 'no exact Codex thread identity is registered' }
|
|
1205
|
+
const threadId = rec.harnessSessionId
|
|
1206
|
+
const dir = rec.runtimeDir || runtimeRoot()
|
|
1207
|
+
const endpoint = codexEndpointForRecord(rec, dir)
|
|
1208
|
+
if (!endpoint) return { ok: false, error: 'no exact Codex generation binding is registered for this target' }
|
|
1209
|
+
const generation = codexRuntimeGeneration(dir, endpoint)
|
|
1210
|
+
if (!generation) return { ok: false, error: 'Codex shared app-server generation is unproven' }
|
|
1211
|
+
const fence = { dir, endpoint, generation }
|
|
1212
|
+
const before = await codexRunningTurn(endpoint.socketPath, threadId)
|
|
1213
|
+
if (!before.ok) return { ok: false, error: before.error }
|
|
1214
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1215
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt preflight' }
|
|
1216
|
+
if (before.turnPresence === 'idle') return { ok: true }
|
|
1217
|
+
if (before.turnPresence !== 'active' || !before.turnId)
|
|
1218
|
+
return { ok: false, error: `Codex target thread ${threadId} turn state is unknown` }
|
|
1219
|
+
const interrupted = await codexThreadMutation(endpoint.socketPath, 'turn/interrupt', threadId, fence, before.turnId)
|
|
1220
|
+
if (!interrupted.ok) return { ok: false, error: interrupted.error }
|
|
1221
|
+
const deadline = Date.now() + CODEX_INTERRUPT_SETTLE_MS
|
|
1222
|
+
for (;;) {
|
|
1223
|
+
const after = await codexRunningTurn(endpoint.socketPath, threadId)
|
|
1224
|
+
if (!after.ok) return { ok: false, error: after.error }
|
|
1225
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1226
|
+
return { ok: false, error: 'shared Codex app-server generation changed during interrupt settlement' }
|
|
1227
|
+
if (after.turnPresence === 'idle') return { ok: true }
|
|
1228
|
+
if (after.turnPresence === 'unknown') return { ok: false, error: `Codex target thread ${threadId} turn state is unknown after interrupt` }
|
|
1229
|
+
if (Date.now() >= deadline) return { ok: false, error: `Codex target thread ${threadId} remained active after interrupt` }
|
|
1230
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1089
1234
|
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
1090
1235
|
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
1091
1236
|
export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
|
|
1092
1237
|
return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
|
|
1093
1238
|
}
|
|
1094
1239
|
|
|
1240
|
+
// Every thread/list row carries the app-server's live turn state for that thread, in the protocol's own
|
|
1241
|
+
// three variants. `notLoaded` duplicates what thread/loaded/list reports; `idle`/`active` answer the only
|
|
1242
|
+
// question a lifecycle gate asks. Any other shape is `unknown` and fails closed — never derived from
|
|
1243
|
+
// something cheaper.
|
|
1244
|
+
type CodexThreadStatus = 'notLoaded' | 'idle' | 'active' | 'unknown'
|
|
1245
|
+
const codexRowStatus = (row: { status?: unknown }): CodexThreadStatus => {
|
|
1246
|
+
const type = (row.status as { type?: unknown } | null | undefined)?.type
|
|
1247
|
+
return type === 'notLoaded' || type === 'idle' || type === 'active' ? type : 'unknown'
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1095
1250
|
type CodexThreadCollectionResult =
|
|
1096
|
-
| { ok: true; ids: string[]; parentById: Map<string, string | null> }
|
|
1251
|
+
| { ok: true; ids: string[]; parentById: Map<string, string | null>; statusById: Map<string, CodexThreadStatus> }
|
|
1097
1252
|
| { ok: false; error: string }
|
|
1098
1253
|
|
|
1099
1254
|
function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
|
|
@@ -1101,6 +1256,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1101
1256
|
? params.sourceKinds
|
|
1102
1257
|
: [...CODEX_THREAD_SOURCE_KINDS]
|
|
1103
1258
|
const parentById = new Map<string, string | null>()
|
|
1259
|
+
const statusById = new Map<string, CodexThreadStatus>()
|
|
1104
1260
|
const conflictingParents = new Set<string>()
|
|
1105
1261
|
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
1106
1262
|
if (typeof item === 'string') return item
|
|
@@ -1108,26 +1264,40 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1108
1264
|
return typeof id === 'string' ? id : null
|
|
1109
1265
|
}, 'thread/list', (item) => {
|
|
1110
1266
|
if (!item || typeof item !== 'object') return
|
|
1111
|
-
const row = item as { id?: unknown; parentThreadId?: unknown }
|
|
1267
|
+
const row = item as { id?: unknown; parentThreadId?: unknown; status?: unknown }
|
|
1112
1268
|
if (typeof row.id !== 'string') return
|
|
1113
1269
|
const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
|
|
1114
1270
|
if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
|
|
1115
1271
|
parentById.set(row.id, parent)
|
|
1272
|
+
// Parent ownership is a fact about the graph, so a disagreement across pages is a census fault.
|
|
1273
|
+
// Turn state is live, so a mid-drain change is not a fault — it is simply no longer knowable here.
|
|
1274
|
+
const status = codexRowStatus(row)
|
|
1275
|
+
statusById.set(row.id, statusById.has(row.id) && statusById.get(row.id) !== status ? 'unknown' : status)
|
|
1116
1276
|
}).then((result) => {
|
|
1117
1277
|
if (!result.ok) return result
|
|
1118
1278
|
if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
|
|
1119
|
-
return { ...result, parentById }
|
|
1279
|
+
return { ...result, parentById, statusById }
|
|
1120
1280
|
})
|
|
1121
1281
|
}
|
|
1122
1282
|
|
|
1283
|
+
// The gate's question is about the tip — is a turn in flight right now — and thread/list already answers it
|
|
1284
|
+
// for every thread at once, at a cost that tracks how many threads exist. Reading turns instead costs the
|
|
1285
|
+
// target's whole persisted history against a fixed budget, so a long-lived session becomes unmutatable.
|
|
1286
|
+
const codexPresenceFromStatus = (status: CodexThreadStatus | undefined): SharedRuntimeMutationGuard['targetTurnPresence'] =>
|
|
1287
|
+
status === 'idle' || status === 'active' ? status : 'unknown'
|
|
1288
|
+
|
|
1123
1289
|
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
|
|
1124
1290
|
const generationBefore = codexMutationGeneration(dir, endpoint)
|
|
1125
1291
|
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1126
1292
|
const sock = endpoint.socketPath
|
|
1127
|
-
|
|
1293
|
+
// The descendant collections are ancestor-filtered and therefore exclude the target itself, so the
|
|
1294
|
+
// target's own turn state comes from the whole-collection census. These run concurrently with the rest.
|
|
1295
|
+
const [loaded, activeDescendants, archivedDescendants, activeList, archivedList] = await Promise.all([
|
|
1128
1296
|
codexLoadedReferenceIds(sock),
|
|
1129
1297
|
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1130
1298
|
codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1299
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1300
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1131
1301
|
])
|
|
1132
1302
|
const referenceIds = loaded.ok ? loaded.referenceIds : []
|
|
1133
1303
|
const descendantIds = activeDescendants.ok && archivedDescendants.ok
|
|
@@ -1136,12 +1306,11 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), e
|
|
|
1136
1306
|
if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
|
|
1137
1307
|
if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
|
|
1138
1308
|
if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
|
|
1139
|
-
|
|
1140
|
-
if (
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1309
|
+
if (!activeList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeList.error }
|
|
1310
|
+
if (!archivedList.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedList.error }
|
|
1311
|
+
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = referenceIds.includes(threadId)
|
|
1312
|
+
? codexPresenceFromStatus(activeList.statusById.get(threadId) ?? archivedList.statusById.get(threadId))
|
|
1313
|
+
: 'none'
|
|
1145
1314
|
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
1146
1315
|
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
|
|
1147
1316
|
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
@@ -1184,7 +1353,7 @@ function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
|
|
|
1184
1353
|
typeof (value as CodexGenerationEndpoint).socketPath === 'string'
|
|
1185
1354
|
}
|
|
1186
1355
|
|
|
1187
|
-
async function
|
|
1356
|
+
async function codexColdPreflightOnce(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
|
|
1188
1357
|
const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
|
|
1189
1358
|
if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1190
1359
|
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
@@ -1193,8 +1362,8 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1193
1362
|
codexLoadedReferenceIds(sock),
|
|
1194
1363
|
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1195
1364
|
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1196
|
-
|
|
1197
|
-
|
|
1365
|
+
codexThreadCollection(sock, { archived: true, sourceKinds: [] }),
|
|
1366
|
+
codexThreadCollection(sock, { archived: false, sourceKinds: [] }),
|
|
1198
1367
|
])
|
|
1199
1368
|
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1200
1369
|
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
|
|
@@ -1246,18 +1415,20 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1246
1415
|
}
|
|
1247
1416
|
}
|
|
1248
1417
|
|
|
1418
|
+
// Every subtree member was just proven to occur in exactly one whole-collection census, so that census
|
|
1419
|
+
// already carries each one's live turn state. No second round of native reads, and therefore no second
|
|
1420
|
+
// generation fence — nothing was read between the fence above and here.
|
|
1421
|
+
const statusById = new Map([...activeList.statusById, ...archivedList.statusById])
|
|
1249
1422
|
const loadedSet = new Set(loaded.referenceIds)
|
|
1250
1423
|
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
return { ok: false, reason:
|
|
1254
|
-
|
|
1255
|
-
if (!state.ok) return { ok: false, reason: state.error }
|
|
1256
|
-
if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1257
|
-
if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1424
|
+
for (const id of loadedSubtreeIds) {
|
|
1425
|
+
const presence = codexPresenceFromStatus(statusById.get(id))
|
|
1426
|
+
if (presence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1427
|
+
if (presence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1258
1428
|
if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
|
|
1259
1429
|
}
|
|
1260
1430
|
|
|
1431
|
+
// Proven, not assumed: a loaded target is one of the members the loop above just cleared.
|
|
1261
1432
|
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
|
|
1262
1433
|
const guard: SharedRuntimeMutationGuard = {
|
|
1263
1434
|
healthy: true,
|
|
@@ -1286,6 +1457,28 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1286
1457
|
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
|
|
1287
1458
|
}
|
|
1288
1459
|
|
|
1460
|
+
// A busy app-server can refuse one WebSocket census while accepting the next. The refusal is transport-local,
|
|
1461
|
+
// so retry the complete proof (including generation fencing) within the terminal operation's finite budget;
|
|
1462
|
+
// semantic ownership refusals still return immediately and never turn into repeated native reads.
|
|
1463
|
+
const CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS = 6
|
|
1464
|
+
const CODEX_COLD_PREFLIGHT_RETRY_MS = 250
|
|
1465
|
+
const CODEX_COLD_PREFLIGHT_DEADLINE_MS = 30_000
|
|
1466
|
+
const isTransientCodexCensusFailure = (reason: string): boolean =>
|
|
1467
|
+
/(?:timed out|connection|closed during|refused .*census|census failed|app-server busy)/i.test(reason)
|
|
1468
|
+
|
|
1469
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
|
|
1470
|
+
const deadline = Date.now() + CODEX_COLD_PREFLIGHT_DEADLINE_MS
|
|
1471
|
+
for (let attempt = 0; attempt < CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS; attempt++) {
|
|
1472
|
+
const result = await codexColdPreflightOnce(threadId, dir, expectedGeneration, endpoint)
|
|
1473
|
+
if (result.ok || !isTransientCodexCensusFailure(result.reason) || attempt === CODEX_COLD_PREFLIGHT_MAX_ATTEMPTS - 1) return result
|
|
1474
|
+
const remaining = deadline - Date.now()
|
|
1475
|
+
if (remaining <= 0) return result
|
|
1476
|
+
const delay = Math.min(CODEX_COLD_PREFLIGHT_RETRY_MS * 2 ** attempt, remaining)
|
|
1477
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
1478
|
+
}
|
|
1479
|
+
throw new Error('unreachable Codex cold preflight retry state')
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1289
1482
|
async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
|
|
1290
1483
|
const dir = runtimeRoot()
|
|
1291
1484
|
const generation = codexMutationGeneration(dir)
|
|
@@ -1315,8 +1508,16 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
|
|
|
1315
1508
|
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
|
|
1316
1509
|
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
|
|
1317
1510
|
const legacy = legacyCodexGenerationEndpoint(dir)
|
|
1318
|
-
|
|
1319
|
-
|
|
1511
|
+
// Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
|
|
1512
|
+
// loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
|
|
1513
|
+
let orphanBudgetMs = CODEX_MUTATION_BASE_MS
|
|
1514
|
+
if (plan.guard.referenceIds.includes(threadId)) {
|
|
1515
|
+
const rollout = codexRolloutBytes(threadId)
|
|
1516
|
+
if ('unreadable' in rollout) return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` }
|
|
1517
|
+
orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes)
|
|
1518
|
+
}
|
|
1519
|
+
const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs)
|
|
1520
|
+
if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` }
|
|
1320
1521
|
const after = await codexColdPreflight(threadId, dir, generation)
|
|
1321
1522
|
const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
|
|
1322
1523
|
if (!after.ok) {
|
|
@@ -1745,19 +1946,25 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
|
|
|
1745
1946
|
// thread/start+turn but does NOT persist the rollout for its first ~2-4s (a warm-up window) — the SAME thread's
|
|
1746
1947
|
// rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
|
|
1747
1948
|
// found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
|
|
1748
|
-
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
1749
1949
|
// does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
|
|
1750
1950
|
// (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
|
|
1751
1951
|
// the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
|
|
1752
1952
|
// dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
|
|
1753
1953
|
// every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
|
|
1754
1954
|
// "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
|
|
1755
|
-
export function codexRolloutExists(threadId: string, root
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1955
|
+
export function codexRolloutExists(threadId: string, root?: string): boolean {
|
|
1956
|
+
return codexRolloutPath(threadId, root) !== null
|
|
1957
|
+
}
|
|
1958
|
+
// The same day-dir walk, answering how big that rollout is. `thread/archive` on a LOADED thread flushes the
|
|
1959
|
+
// thread's in-memory rollout inside the server's shutdown_and_wait before it commits, so this size IS the work
|
|
1960
|
+
// an archive asks for; a notLoaded thread flushes nothing and its size is irrelevant. NO rollout file is a real
|
|
1961
|
+
// `0`, not an error: a thread that has started but not yet persisted (thread/start alone writes none, and a
|
|
1962
|
+
// fresh app-server lags 2-4s) has nothing to flush, so refusing it would be a false refusal. Only a file that
|
|
1963
|
+
// exists and cannot be measured is unreadable, and that fails closed rather than passing as small.
|
|
1964
|
+
export function codexRolloutBytes(threadId: string, root?: string): { bytes: number } | { unreadable: true } {
|
|
1965
|
+
const path = codexRolloutPath(threadId, root)
|
|
1966
|
+
if (path) { try { return { bytes: statSync(path).size } } catch { return { unreadable: true } } }
|
|
1967
|
+
return { bytes: 0 }
|
|
1761
1968
|
}
|
|
1762
1969
|
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
1763
1970
|
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
@@ -2027,6 +2234,9 @@ const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToo
|
|
|
2027
2234
|
// agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
|
|
2028
2235
|
// real gap as codex.
|
|
2029
2236
|
const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
2237
|
+
// z-code reads Claude-compatible hooks but has neither the idle Notification nor StopFailure lifecycle event.
|
|
2238
|
+
// This is a real harness difference, not a TODO: the existing Claude-only idle state is unavailable.
|
|
2239
|
+
const ZCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
2030
2240
|
|
|
2031
2241
|
// the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
|
|
2032
2242
|
// launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
|
|
@@ -2037,6 +2247,7 @@ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
|
|
|
2037
2247
|
const codexBaseCmd = (cmd?: string) => cmd || 'codex'
|
|
2038
2248
|
const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
|
|
2039
2249
|
const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
|
|
2250
|
+
const zcodeBaseCmd = (cmd?: string) => cmd || 'zcode'
|
|
2040
2251
|
|
|
2041
2252
|
// @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
|
|
2042
2253
|
// opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
|
|
@@ -2072,6 +2283,9 @@ const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDi
|
|
|
2072
2283
|
const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
2073
2284
|
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
|
|
2074
2285
|
|
|
2286
|
+
const panePidLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane) =>
|
|
2287
|
+
(tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline')
|
|
2288
|
+
|
|
2075
2289
|
const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
|
|
2076
2290
|
|
|
2077
2291
|
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
@@ -2121,6 +2335,7 @@ export const claudeHarness: Harness = {
|
|
|
2121
2335
|
events: CLAUDE_EVENTS,
|
|
2122
2336
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
2123
2337
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
2338
|
+
executionTrace: readProjectJsonlExecutionTrace,
|
|
2124
2339
|
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
2125
2340
|
baseCmd: claudeBaseCmd,
|
|
2126
2341
|
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
@@ -2142,7 +2357,7 @@ export const claudeHarness: Harness = {
|
|
|
2142
2357
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
2143
2358
|
liveness: socketListenerLiveness,
|
|
2144
2359
|
leafOwnerNeedle: (rec) => rec.session,
|
|
2145
|
-
deliver: (rec, text) =>
|
|
2360
|
+
deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
|
|
2146
2361
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2147
2362
|
coldRuntime: async () => ({ ok: true }),
|
|
2148
2363
|
// The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
|
|
@@ -2221,6 +2436,7 @@ export const codexHarness: Harness = {
|
|
|
2221
2436
|
events: CODEX_EVENTS,
|
|
2222
2437
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
2223
2438
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
2439
|
+
executionTrace: readCodexExecutionTrace,
|
|
2224
2440
|
launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
|
|
2225
2441
|
baseCmd: codexBaseCmd,
|
|
2226
2442
|
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
@@ -2284,6 +2500,7 @@ export const codexHarness: Harness = {
|
|
|
2284
2500
|
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
2285
2501
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2286
2502
|
observeTurnFailures: codexTurnFailureObserver,
|
|
2503
|
+
interrupt: interruptCodexTurn,
|
|
2287
2504
|
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2288
2505
|
targetDescriptorKey: (rec) => {
|
|
2289
2506
|
const endpoint = codexEndpointForRecord(rec)
|
|
@@ -2359,9 +2576,24 @@ export const codexHarness: Harness = {
|
|
|
2359
2576
|
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
|
|
2360
2577
|
return { ok: true }
|
|
2361
2578
|
}
|
|
2579
|
+
const loadedSet = new Set(plan.guard.referenceIds)
|
|
2362
2580
|
for (const id of plan.activeIds) {
|
|
2363
|
-
|
|
2364
|
-
|
|
2581
|
+
// Only a loaded member pays the rollout flush, and an unreadable size must not become a small budget,
|
|
2582
|
+
// so it fails closed before the server is asked to mutate anything.
|
|
2583
|
+
let budgetMs = CODEX_MUTATION_BASE_MS
|
|
2584
|
+
if (loadedSet.has(id)) {
|
|
2585
|
+
const rollout = codexRolloutBytes(id)
|
|
2586
|
+
if ('unreadable' in rollout) return compensate(`Codex subtree member ${id} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown`)
|
|
2587
|
+
budgetMs = codexArchiveBudgetMs(rollout.bytes)
|
|
2588
|
+
}
|
|
2589
|
+
const archived = await codexThreadMutation(sock, 'thread/archive', id, fence, undefined, budgetMs)
|
|
2590
|
+
if (archived.ok) continue
|
|
2591
|
+
const reason = `${archived.error} while archiving Codex subtree member ${id}`
|
|
2592
|
+
// Compensating an unknown commit is what turns one slow member into a false "compensation failed": the
|
|
2593
|
+
// unarchive queues behind an archive the server is still executing and times out too. Report the unknown
|
|
2594
|
+
// commit instead — that is the recovery token resume already reconciles.
|
|
2595
|
+
if (archived.commit === 'unknown') return { ok: false, reason: `${reason}; commit state is unknown and no compensation was attempted` }
|
|
2596
|
+
return compensate(reason)
|
|
2365
2597
|
}
|
|
2366
2598
|
let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
|
|
2367
2599
|
const verifyDeadline = Date.now() + 30_000
|
|
@@ -2552,6 +2784,7 @@ export const piHarness: Harness = {
|
|
|
2552
2784
|
events: PI_EVENTS,
|
|
2553
2785
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
2554
2786
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
2787
|
+
executionTrace: readSessionJsonlExecutionTrace,
|
|
2555
2788
|
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
2556
2789
|
baseCmd: piBaseCmd,
|
|
2557
2790
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
@@ -2602,6 +2835,44 @@ export const piHeadlessHarness: Harness = {
|
|
|
2602
2835
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
2603
2836
|
}
|
|
2604
2837
|
|
|
2838
|
+
const ZCODE_CONTROL_UNAVAILABLE = 'zcode has no control channel; start a new session instead of delivering to an existing one'
|
|
2839
|
+
|
|
2840
|
+
// z-code's app-server is stdin/stdout NDJSON, unlike Codex's Unix-socket WebSocket + thread RPC. This row
|
|
2841
|
+
// intentionally covers the one-turn `--prompt` launcher and Claude-compatible hooks only; control operations
|
|
2842
|
+
// refuse rather than pretending the incompatible protocol accepted them.
|
|
2843
|
+
export const zcodeHarness: Harness = {
|
|
2844
|
+
id: 'zcode',
|
|
2845
|
+
dispatchId: 'zcode',
|
|
2846
|
+
headless: true,
|
|
2847
|
+
launchOneShot: true,
|
|
2848
|
+
events: ZCODE_EVENTS,
|
|
2849
|
+
ownsRendezvous: false,
|
|
2850
|
+
paneTitleIsSelfSummary: false,
|
|
2851
|
+
executionTrace: noExecutionTrace,
|
|
2852
|
+
launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
|
|
2853
|
+
baseCmd: zcodeBaseCmd,
|
|
2854
|
+
sessionIdArg: () => '',
|
|
2855
|
+
sessionEnvVar: 'ZCODE_SESSION_ID',
|
|
2856
|
+
launchEnv: noLaunchEnv,
|
|
2857
|
+
shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
|
|
2858
|
+
shimScope: 'tree',
|
|
2859
|
+
worktreeHookAnchor: () => null,
|
|
2860
|
+
contractFiles: (proj) => [join(proj, 'AGENTS.md')],
|
|
2861
|
+
skillDir: (proj) => join(proj, '.zcode', 'skills'),
|
|
2862
|
+
agentDir: (proj) => join(proj, '.zcode', 'agents'),
|
|
2863
|
+
shim: (dispatch, spex) => buildShim('zcode', ZCODE_EVENTS, dispatch, spex),
|
|
2864
|
+
writeTrust: () => [],
|
|
2865
|
+
removeTrust: () => { /* z-code wrote no trust artifact */ },
|
|
2866
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2867
|
+
slashCommands: () => [],
|
|
2868
|
+
liveness: panePidLiveness,
|
|
2869
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
2870
|
+
deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
|
|
2871
|
+
cleanupRuntime: async () => { /* one-shot z-code owns no SpexCode transport to remove */ },
|
|
2872
|
+
coldRuntime: async () => ({ ok: true }),
|
|
2873
|
+
resumeArg: () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE) },
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2605
2876
|
export const opencodeHarness: Harness = {
|
|
2606
2877
|
id: 'opencode',
|
|
2607
2878
|
dispatchId: 'opencode',
|
|
@@ -2611,6 +2882,7 @@ export const opencodeHarness: Harness = {
|
|
|
2611
2882
|
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
2612
2883
|
ownsRendezvous: true,
|
|
2613
2884
|
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
2885
|
+
executionTrace: readLocalStoreExecutionTrace,
|
|
2614
2886
|
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
2615
2887
|
baseCmd: opencodeBaseCmd,
|
|
2616
2888
|
sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
|
|
@@ -2674,14 +2946,20 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
2674
2946
|
}
|
|
2675
2947
|
|
|
2676
2948
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
2677
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
2949
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
2678
2950
|
|
|
2679
2951
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
2680
2952
|
export const defaultHarness: Harness = claudeHarness
|
|
2681
2953
|
|
|
2954
|
+
// the registry lookup as DATA. A sweep over records nobody is currently asking about (a removed plugin, a
|
|
2955
|
+
// renamed id) must report an unresolvable harness rather than abort, so it resolves through this.
|
|
2956
|
+
export function harnessByIdOrNull(id: string): Harness | null {
|
|
2957
|
+
return HARNESSES.find((x) => x.id === id) ?? null
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2682
2960
|
// resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
|
|
2683
2961
|
export function harnessById(id: string): Harness {
|
|
2684
|
-
const h =
|
|
2962
|
+
const h = harnessByIdOrNull(id)
|
|
2685
2963
|
if (!h) throw new Error(`unknown harness '${id}' (known: ${HARNESSES.map((x) => x.id).join(', ')})`)
|
|
2686
2964
|
return h
|
|
2687
2965
|
}
|