spexcode 0.5.2 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +946 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs'
|
|
5
5
|
import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
8
8
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
9
9
|
import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
|
|
10
|
-
import { defaultHarness, defaultLauncher, harnessById, procSnapshot, resolveLauncher,
|
|
10
|
+
import { adapterLoadedReferenceState, defaultHarness, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
|
-
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
|
+
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
13
13
|
import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
14
14
|
import { stripRefSigil } from './mentions.js'
|
|
15
|
+
import { shQuote } from './sh.js'
|
|
16
|
+
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
17
|
+
import { processStartToken } from './process-identity.js'
|
|
18
|
+
import { maintenanceBrokerDescriptors, runSessionOperation, runSessionOperationSync, SessionMaintenanceError, type Authorization, type MaintenanceTicket } from './session-maintenance.js'
|
|
15
19
|
|
|
16
20
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
17
21
|
// SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
|
|
@@ -72,26 +76,31 @@ function maxActive(): number {
|
|
|
72
76
|
return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE))
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// (rvEnv, below) and the best-effort socket sweep on close.
|
|
79
|
-
// env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
|
|
80
|
-
// so its hooks + materialize write to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
79
|
+
// The adapter owns any transport bootstrap env (rendezvous daemon + socket); product launch only composes it
|
|
80
|
+
// with the governed session id and home vars. This env prefix also ensures hooks + materialize write to the
|
|
81
|
+
// SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
81
82
|
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
82
83
|
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
83
84
|
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
84
85
|
const rvEnv = (id: string, harness = HARNESS) => {
|
|
85
|
-
// SPEXCODE_SESSION_ID is the governed record id
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
// SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
|
|
87
|
+
// session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
|
|
88
|
+
// a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
|
|
89
|
+
// once, at the boundary; nothing downstream re-verifies it, because after this a session-identity variable
|
|
90
|
+
// exists in a process only if that process belongs to that session — either set right here, or stamped by
|
|
91
|
+
// the harness itself for its own acting conversation ([[harness-adapter]]). The same strip runs on the one
|
|
92
|
+
// other process we own that is NOT a session's own — codex's shared app-server, whose leaked inherited id
|
|
93
|
+
// was github#76.
|
|
94
|
+
const scrub = sessionIdentityEnvVars().map((v) => `-u ${v}`)
|
|
95
|
+
const homeVars = ['SPEXCODE_HOME', 'CODEX_HOME'].flatMap((v) => {
|
|
96
|
+
const value = process.env[v]
|
|
97
|
+
return value ? [`${v}=${value}`] : []
|
|
98
|
+
})
|
|
99
|
+
return [...scrub,
|
|
100
|
+
`SPEXCODE_SESSION_ID=${id}`,
|
|
101
|
+
`SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
|
|
102
|
+
`SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
|
|
103
|
+
...harness.launchEnv(id), ...homeVars].join(' ')
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
// the prompt-dispatch outcome type + its claude/codex delivery implementations live in the [[harness-adapter]]
|
|
@@ -99,9 +108,12 @@ const rvEnv = (id: string, harness = HARNESS) => {
|
|
|
99
108
|
// for the existing importers (client.ts) that read it off the sessions module.
|
|
100
109
|
export type { DispatchResult }
|
|
101
110
|
|
|
102
|
-
export type Lifecycle =
|
|
103
|
-
export type Proposal =
|
|
104
|
-
|
|
111
|
+
export type Lifecycle = SessionLifecycle
|
|
112
|
+
export type Proposal = SessionProposal
|
|
113
|
+
// `corrupt` and `retired` are the two RECORD-INTEGRITY readings — neither a lifecycle the agent authored nor a
|
|
114
|
+
// liveness the runtime probed, but the honest answer when the record itself can no longer carry either: its
|
|
115
|
+
// bytes don't parse, or the worktree it names is gone. They exist so such a row can never silently vanish.
|
|
116
|
+
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown' | 'corrupt' | 'retired'
|
|
105
117
|
// liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
|
|
106
118
|
// authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
|
|
107
119
|
// override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
|
|
@@ -117,9 +129,11 @@ export type Session = {
|
|
|
117
129
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
118
130
|
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
119
131
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
120
|
-
capabilities: { headless: boolean
|
|
132
|
+
capabilities: { headless: boolean } // stable adapter projection; console surfaces consume data, never harness ids
|
|
121
133
|
launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
|
|
122
134
|
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
135
|
+
archived: boolean // cold storage ([[archive]]) — successful records are offline; default views exclude them
|
|
136
|
+
archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
|
|
123
137
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
124
138
|
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
125
139
|
}
|
|
@@ -228,12 +242,17 @@ export type SessRec = {
|
|
|
228
242
|
parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
|
|
229
243
|
status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
|
|
230
244
|
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
245
|
+
stopped: boolean // explicit human stop; liveness metadata, never an agent-authored lifecycle value
|
|
246
|
+
archived: boolean // shelved by the human ([[archive]]) — only clean after coldProof is written
|
|
247
|
+
coldProof?: string | null // durable exact leaf + adapter unload proof; missing on legacy archives => visible hazard
|
|
248
|
+
adapterRecovery?: string | null // explicit adapter recovery state after an uncertain partial cold mutation
|
|
231
249
|
launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
|
|
232
250
|
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
233
251
|
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
252
|
+
launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
|
|
234
253
|
}
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
|
|
255
|
+
type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
|
|
237
256
|
export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
|
|
238
257
|
|
|
239
258
|
// @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
|
|
@@ -264,35 +283,224 @@ export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, aut
|
|
|
264
283
|
// readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
|
|
265
284
|
// on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
|
|
266
285
|
function readRecord(id: string): SessRec | null {
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
269
|
-
|
|
286
|
+
const entry = readAliasedRecordEntry(id)
|
|
287
|
+
if (entry.kind === 'absent') return null
|
|
288
|
+
if (entry.kind === 'corrupt') throw new SessionRecordUnusable('corrupt', id, corruptReason(entry))
|
|
289
|
+
try { return fromRaw(entry.raw) }
|
|
290
|
+
catch (error) {
|
|
291
|
+
throw new SessionRecordUnusable('corrupt', id,
|
|
292
|
+
`session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// @@@ SessionRecordUnusable - the record exists but cannot carry state, for one of two reasons, and BOTH must
|
|
296
|
+
// stop a writer rather than let it invent one. `corrupt`: the bytes don't parse, so writing would DESTROY the
|
|
297
|
+
// evidence of what broke and resurrect the session as a plausible-looking empty shell (the reported failure —
|
|
298
|
+
// a damaged record came back as a valid `idle` record and even had a launch script regenerated for it).
|
|
299
|
+
// `retired`: the work merged and the worktree the record names is gone, so there is nothing left to be active
|
|
300
|
+
// IN. Readers that enumerate (the board) catch this and render the row; writers let it out, loud.
|
|
301
|
+
export class SessionRecordUnusable extends Error {
|
|
302
|
+
constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
|
|
303
|
+
super(message)
|
|
304
|
+
this.name = 'SessionRecordUnusable'
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const corruptReason = (e: { path: string; error: string }): string =>
|
|
308
|
+
`session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`
|
|
309
|
+
// a record whose worktree is gone names work that no longer exists on disk. That is the manual-retirement end
|
|
310
|
+
// state (merged, worktree and branch removed, record left behind), and it is terminal: no lifecycle writer may
|
|
311
|
+
// put such a session back to work, and no launch may be assembled for a directory that isn't there.
|
|
312
|
+
function retirementReason(rec: SessRec): string | null {
|
|
313
|
+
if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
|
|
314
|
+
return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`
|
|
315
|
+
}
|
|
316
|
+
// the read every LIFECYCLE writer uses: it additionally refuses a retired record. Metadata verbs (rename,
|
|
317
|
+
// sort, archive) and `close` deliberately keep using readRecord — filing and removal stay available on a row
|
|
318
|
+
// whose work is gone.
|
|
319
|
+
function readLiveRecord(id: string): SessRec | null {
|
|
320
|
+
const rec = readRecord(id)
|
|
321
|
+
if (!rec) return null
|
|
322
|
+
const retired = retirementReason(rec)
|
|
323
|
+
if (retired) throw new SessionRecordUnusable('retired', rec.session, retired)
|
|
324
|
+
return rec
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Cross-process lifecycle serialization. Hooks and operator commands are separate CLI processes, so the
|
|
328
|
+
// in-memory transition tail is only an optimization. This lock covers each read/modify/write or destructive
|
|
329
|
+
// transition across archive/resume/stop/close and hook writers. It lives outside the record directory so close
|
|
330
|
+
// may remove the record while its lock is held. A dead writer's lock is reclaimed; a live writer is waited for
|
|
331
|
+
// with a bounded wall and then fails loudly rather than allowing a stale write to win.
|
|
332
|
+
const recordLockRoot = () => join(runtimeRoot(), '.session-locks')
|
|
333
|
+
const recordLockPath = (id: string) => join(recordLockRoot(), `${id}.lock`)
|
|
334
|
+
const syncPause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
335
|
+
function acquireRecordLockSync(id: string, timeoutMs = 30_000): () => void {
|
|
336
|
+
mkdirSync(recordLockRoot(), { recursive: true })
|
|
337
|
+
const path = recordLockPath(id), deadline = Date.now() + timeoutMs
|
|
338
|
+
for (;;) {
|
|
339
|
+
try {
|
|
340
|
+
const fd = openSync(path, 'wx')
|
|
341
|
+
writeSync(fd, String(process.pid))
|
|
342
|
+
closeSync(fd)
|
|
343
|
+
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
344
|
+
} catch (e) {
|
|
345
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
346
|
+
let owner = 0
|
|
347
|
+
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
348
|
+
if (owner && owner !== process.pid) {
|
|
349
|
+
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
350
|
+
}
|
|
351
|
+
if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
|
|
352
|
+
syncPause(10)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function acquireRecordLock(id: string, timeoutMs = 30_000): Promise<() => void> {
|
|
357
|
+
mkdirSync(recordLockRoot(), { recursive: true })
|
|
358
|
+
const path = recordLockPath(id), deadline = Date.now() + timeoutMs
|
|
359
|
+
for (;;) {
|
|
360
|
+
try {
|
|
361
|
+
const fd = openSync(path, 'wx')
|
|
362
|
+
writeSync(fd, String(process.pid))
|
|
363
|
+
closeSync(fd)
|
|
364
|
+
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
365
|
+
} catch (e) {
|
|
366
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
367
|
+
let owner = 0
|
|
368
|
+
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
369
|
+
if (owner && owner !== process.pid) {
|
|
370
|
+
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
371
|
+
}
|
|
372
|
+
if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
|
|
373
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function withRecordLock<T>(id: string, body: () => Promise<T>): Promise<T> {
|
|
378
|
+
const release = await acquireRecordLock(id)
|
|
379
|
+
try { return await body() } finally { release() }
|
|
380
|
+
}
|
|
381
|
+
function withRecordLockSync<T>(id: string, body: () => T): T {
|
|
382
|
+
const release = acquireRecordLockSync(id)
|
|
383
|
+
try { return body() } finally { release() }
|
|
384
|
+
}
|
|
385
|
+
function tryRecordLockSync(id: string): (() => void) | null {
|
|
386
|
+
mkdirSync(recordLockRoot(), { recursive: true })
|
|
387
|
+
const path = recordLockPath(id)
|
|
388
|
+
try {
|
|
389
|
+
const fd = openSync(path, 'wx')
|
|
390
|
+
writeSync(fd, String(process.pid))
|
|
391
|
+
closeSync(fd)
|
|
392
|
+
return () => { try { unlinkSync(path) } catch { /* another recovery already removed it */ } }
|
|
393
|
+
} catch (e) {
|
|
394
|
+
if ((e as NodeJS.ErrnoException).code === 'EEXIST') return null
|
|
395
|
+
throw e
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
|
|
399
|
+
// enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
|
|
400
|
+
// and then race a just-queued TUI turn.
|
|
401
|
+
export function withSessionInputLock<T>(id: string, body: () => T): T | null {
|
|
402
|
+
// PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
|
|
403
|
+
// regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
|
|
404
|
+
const release = tryRecordLockSync(id)
|
|
405
|
+
if (!release) return null
|
|
406
|
+
try { return body() } finally { release() }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const COLD_PROOF_VERSION = 'cold-v1'
|
|
410
|
+
function coldProofFor(rec: Pick<SessRec, 'session' | 'harness' | 'harnessSessionId'>): string {
|
|
411
|
+
const adapter = harnessById(rec.harness || defaultHarness.id).id
|
|
412
|
+
const exact = rec.harnessSessionId ? `thread:${rec.harnessSessionId}` : 'no-resident-ref'
|
|
413
|
+
return `${COLD_PROOF_VERSION}|${adapter}|${rec.session}|${exact}`
|
|
414
|
+
}
|
|
415
|
+
function hasValidColdProof(rec: SessRec): boolean {
|
|
416
|
+
return !!rec.coldProof && rec.coldProof === coldProofFor(rec)
|
|
270
417
|
}
|
|
271
418
|
// the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
|
|
272
419
|
// claude, absent pin → null) are unit-auditable without a store on disk.
|
|
273
420
|
export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
274
421
|
const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
|
|
275
|
-
const status = ownedQueue ? 'queued' :
|
|
422
|
+
const status = ownedQueue ? 'queued' : isSessionLifecycle(raw.status) ? raw.status : 'active'
|
|
276
423
|
const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
|
|
277
424
|
if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
|
|
278
|
-
const proposal =
|
|
425
|
+
const proposal = isSessionProposal(raw.proposal) ? raw.proposal : null
|
|
279
426
|
const sk = raw.sortkey
|
|
280
427
|
const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
|
|
428
|
+
const pendingRaw = rawLaunchReadinessOriginal(raw)
|
|
429
|
+
const pendingStatus = pendingRaw && isSessionLifecycle(pendingRaw.status) ? pendingRaw.status : null
|
|
430
|
+
if (pendingRaw && !pendingStatus) throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`)
|
|
431
|
+
const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null
|
|
432
|
+
if (pendingRaw?.proposal && !pendingProposal) throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`)
|
|
281
433
|
return {
|
|
282
434
|
session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
|
|
283
435
|
node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
|
|
284
436
|
status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
285
437
|
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
286
438
|
harnessSessionId: raw.harness_session_id || null,
|
|
439
|
+
stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
|
|
440
|
+
archived: !!raw.archived, // records written before archive → absent → not shelved
|
|
441
|
+
coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
|
|
442
|
+
adapterRecovery: raw.adapter_recovery || null,
|
|
287
443
|
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
288
444
|
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
289
445
|
launchOwner: launchOwner || null,
|
|
446
|
+
launchReadinessPending: pendingRaw ? {
|
|
447
|
+
version: 1,
|
|
448
|
+
startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
|
|
449
|
+
original: {
|
|
450
|
+
status: pendingStatus!, proposal: pendingProposal, note: pendingRaw.note || null,
|
|
451
|
+
stopped: pendingRaw.stopped, archived: pendingRaw.archived,
|
|
452
|
+
coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
|
|
453
|
+
},
|
|
454
|
+
} : null,
|
|
290
455
|
}
|
|
291
456
|
}
|
|
457
|
+
|
|
458
|
+
function publicRecord(rec: SessRec): SessRec {
|
|
459
|
+
const original = rec.launchReadinessPending?.original
|
|
460
|
+
return original ? { ...rec, ...original } : rec
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function launchReadinessPending(original: SessRec): LaunchReadinessPending {
|
|
464
|
+
return {
|
|
465
|
+
version: 1,
|
|
466
|
+
startedAt: Date.now(),
|
|
467
|
+
original: {
|
|
468
|
+
status: original.status,
|
|
469
|
+
proposal: original.proposal,
|
|
470
|
+
note: original.note,
|
|
471
|
+
stopped: original.stopped,
|
|
472
|
+
archived: original.archived,
|
|
473
|
+
coldProof: original.coldProof ?? null,
|
|
474
|
+
adapterRecovery: original.adapterRecovery ?? null,
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
|
|
480
|
+
const original = rec.launchReadinessPending?.original
|
|
481
|
+
return original ? { ...rec, ...original, launchReadinessPending: null } : rec
|
|
482
|
+
}
|
|
483
|
+
// @@@ the ONE record writer - every field of session.json is produced HERE, by serializing the typed record,
|
|
484
|
+
// and lands by atomic replace (temp file in the same dir, then rename). Nothing else — no hook, no shell, no
|
|
485
|
+
// route — may compose or edit the file's text: a note is arbitrary human/agent prose, so any writer that
|
|
486
|
+
// substitutes it into existing JSON eventually meets a quote, a backslash, or a newline and leaves a record
|
|
487
|
+
// nothing can parse (the reported corruption came from exactly that: a hot-path hook editing the value with
|
|
488
|
+
// sed). The shell hooks READ this file cheaply and delegate every WRITE back through the CLI to this function.
|
|
489
|
+
// The rename is what makes a reader between two writes see one whole record instead of a truncated one.
|
|
490
|
+
//
|
|
292
491
|
// @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
|
|
293
|
-
// present (nulls rendered as "" / the empty value, never an absent key).
|
|
294
|
-
// pure-shell
|
|
295
|
-
//
|
|
492
|
+
// present (nulls rendered as "" / the empty value, never an absent key). The stable shape is what lets the
|
|
493
|
+
// pure-shell hooks answer "is this record already active, with nothing stale to clear?" with three exact-line
|
|
494
|
+
// greps and no jq — a READ fast path, never an edit. So do NOT switch to conditional keys or a compact dump.
|
|
495
|
+
//
|
|
496
|
+
// @@@ the record self-cleans - the object below is a CLOSED key set rebuilt from the typed record on every
|
|
497
|
+
// write, never a merge over what was read. So a field retired from the code is ALSO retired from disk the
|
|
498
|
+
// next time anything touches that record: no migration verb, no GC pass, no accreting graveyard of dead keys.
|
|
499
|
+
// A new field earns its place by being declared HERE and in `fromRaw` — that pairing is what keeps the file a
|
|
500
|
+
// projection of the current type rather than a log of everything it has ever been, and it is also the reason
|
|
501
|
+
// a field MISSING from this object is silently dropped: `stopped`, `archived`, `cold_proof`, and
|
|
502
|
+
// `adapter_recovery` are distinct lifecycle/resource projection fields, so each must be listed and cleared by
|
|
503
|
+
// its own transition rather than one being inferred from another.
|
|
296
504
|
function writeRecord(rec: SessRec): void {
|
|
297
505
|
let previous: SessRec | null = null
|
|
298
506
|
try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
|
|
@@ -316,38 +524,44 @@ function writeRecord(rec: SessRec): void {
|
|
|
316
524
|
createdAt: rec.createdAt,
|
|
317
525
|
harness: rec.harness || 'claude',
|
|
318
526
|
harness_session_id: rec.harnessSessionId ?? '',
|
|
527
|
+
stopped: rec.stopped,
|
|
528
|
+
archived: rec.archived,
|
|
529
|
+
cold_proof: rec.coldProof ?? '',
|
|
530
|
+
adapter_recovery: rec.adapterRecovery ?? '',
|
|
319
531
|
launcher: rec.launcher ?? '',
|
|
320
532
|
launch_cmd: rec.launchCmd ?? '',
|
|
321
533
|
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
534
|
+
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
535
|
+
version: 1,
|
|
536
|
+
startedAt: rec.launchReadinessPending.startedAt,
|
|
537
|
+
original: {
|
|
538
|
+
status: rec.launchReadinessPending.original.status,
|
|
539
|
+
proposal: rec.launchReadinessPending.original.proposal ?? '',
|
|
540
|
+
note: rec.launchReadinessPending.original.note ?? '',
|
|
541
|
+
stopped: rec.launchReadinessPending.original.stopped,
|
|
542
|
+
archived: rec.launchReadinessPending.original.archived,
|
|
543
|
+
cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
|
|
544
|
+
adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
|
|
545
|
+
},
|
|
546
|
+
} : '',
|
|
322
547
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// watchSessions' poll `catch` simply skips the tick with `prev` intact — no fabricated removals.
|
|
340
|
-
async function listWorktrees(): Promise<{ path: string; branch: string | null }[]> {
|
|
341
|
-
const r = await gitTry(['-C', mainRoot(), 'worktree', 'list', '--porcelain'])
|
|
342
|
-
if (!r.ok) throw new Error(`git worktree list failed: ${r.stderr.trim() || 'unknown error'}`)
|
|
343
|
-
const list: { path: string; branch: string | null }[] = []
|
|
344
|
-
let cur: { path: string; branch: string | null } | null = null
|
|
345
|
-
for (const line of r.stdout.split('\n')) {
|
|
346
|
-
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null }; list.push(cur) }
|
|
347
|
-
else if (line.startsWith('branch ') && cur) cur.branch = line.slice(7).replace('refs/heads/', '')
|
|
548
|
+
const dir = sessionStoreDir(rec.session)
|
|
549
|
+
mkdirSync(dir, { recursive: true })
|
|
550
|
+
const path = sessionRecordPath(rec.session)
|
|
551
|
+
const tmp = join(dir, `.session.json.${process.pid}.tmp`)
|
|
552
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
|
|
553
|
+
renameSync(tmp, path) // atomic within the dir: a concurrent reader sees the old record or the new one
|
|
554
|
+
// session.json normally is the current public projection. A launch-readiness candidate is the sole internal
|
|
555
|
+
// exception: its frozen original remains public until validation clears the fence. Persist each PUBLIC moved
|
|
556
|
+
// lifecycle value before this writer returns, so a later write cannot erase a declaration note between
|
|
557
|
+
// observer samples. New-record genesis stays with superviseTimeline; metadata-only writes do not manufacture
|
|
558
|
+
// status events.
|
|
559
|
+
const previousPublic = previous ? publicRecord(previous) : null
|
|
560
|
+
const nextPublic = publicRecord(rec)
|
|
561
|
+
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
562
|
+
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
563
|
+
recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
|
|
348
564
|
}
|
|
349
|
-
if (!list.length) throw new Error('git worktree list returned no worktrees (enumeration failed; the main worktree is always present)')
|
|
350
|
-
return list
|
|
351
565
|
}
|
|
352
566
|
|
|
353
567
|
// @@@ reconcile - the shown status. awaiting → the proposal's label (review/done/close-pending),
|
|
@@ -456,7 +670,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
456
670
|
for (const [id, p] of parseLivePanes(out)) {
|
|
457
671
|
windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) })
|
|
458
672
|
if (p.title) titles.set(id, p.title)
|
|
459
|
-
if (windows.get(id)!.pidAlive === undefined) {
|
|
673
|
+
if (windows.get(id)!.pidAlive === undefined) {
|
|
674
|
+
// A corrupt row has no trustworthy harness to scan and renders liveness=unknown on its own. Letting this
|
|
675
|
+
// optional legacy enrichment throw would turn one diagnosable row into a 409 for the entire board.
|
|
676
|
+
try { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
|
|
677
|
+
catch (e) { if (!(e instanceof SessionRecordUnusable)) throw e }
|
|
678
|
+
}
|
|
460
679
|
}
|
|
461
680
|
// the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
|
|
462
681
|
if (needsCodexProcScan(legacy)) {
|
|
@@ -574,7 +793,7 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
|
|
|
574
793
|
// sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
|
|
575
794
|
// for the grace window; only past it (still not online) is it genuinely 'offline'.
|
|
576
795
|
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
577
|
-
if (!rec.session) return 'offline'
|
|
796
|
+
if (!rec.session || rec.stopped || rec.archived) return 'offline'
|
|
578
797
|
// Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
|
|
579
798
|
// codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
|
|
580
799
|
// stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
|
|
@@ -587,7 +806,15 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
587
806
|
// act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
|
|
588
807
|
if (snap.unproven.has(rec.session)) return 'unknown'
|
|
589
808
|
const at = launchedAt.get(rec.session)
|
|
590
|
-
|
|
809
|
+
if (at && Date.now() - at < BOOT_GRACE_MS) return 'starting'
|
|
810
|
+
// A dead TRANSPORT is not a dead AGENT. The socket path is keyed by session id alone, so a foreign teardown
|
|
811
|
+
// (or a stray rm) can unlink it out from under its own live listener: the agent keeps working, unreachable,
|
|
812
|
+
// and every path-connect ENOENTs — which the adapter axis above reports as proven death. The registered
|
|
813
|
+
// agent.pid is a SECOND, independent witness, and while it still answers, death is UNPROVEN: `unknown`, not
|
|
814
|
+
// the `offline` that disarms the relaunch guard and invites a human to kill a working agent. Same rule as
|
|
815
|
+
// the probe-failure branch (issue #40), one layer down: only a corpse both witnesses agree on is actionable.
|
|
816
|
+
if (agentAlive(rec.session) === true) return 'unknown'
|
|
817
|
+
return 'offline'
|
|
591
818
|
}
|
|
592
819
|
|
|
593
820
|
// reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
|
|
@@ -597,6 +824,10 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
597
824
|
// idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
598
825
|
// terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
|
|
599
826
|
function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
827
|
+
// record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
|
|
828
|
+
// about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
|
|
829
|
+
if (retirementReason(rec)) return 'retired'
|
|
830
|
+
if (rec.archived) return 'offline'
|
|
600
831
|
if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
|
|
601
832
|
if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
|
|
602
833
|
const lv = liveness(rec, snap)
|
|
@@ -613,6 +844,21 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
613
844
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
614
845
|
}
|
|
615
846
|
|
|
847
|
+
// @@@ corruptSession - the row for a record we cannot parse. Every display field the surfaces read is filled
|
|
848
|
+
// from the ONE thing we still know (the id) plus the diagnosis, so the row renders everywhere without any
|
|
849
|
+
// surface having to special-case a half-record. Liveness is `unknown`, not `offline`: we never probed, so we
|
|
850
|
+
// have not proven anything about the agent — the same honesty rule a failed probe follows.
|
|
851
|
+
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
852
|
+
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
853
|
+
return {
|
|
854
|
+
id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
|
|
855
|
+
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
856
|
+
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
857
|
+
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
858
|
+
activity: null, sortKey: null, archiveHazard: null,
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
616
862
|
export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
|
|
617
863
|
const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
|
|
618
864
|
// activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
|
|
@@ -622,7 +868,7 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
622
868
|
const pp = prompt ? promptPreview(prompt) : null
|
|
623
869
|
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
624
870
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
625
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless
|
|
871
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
626
872
|
}
|
|
627
873
|
|
|
628
874
|
// @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
|
|
@@ -632,10 +878,12 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
632
878
|
// any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
|
|
633
879
|
// (the route answers 404). The frontend's right-click rename is the sole caller today.
|
|
634
880
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
881
|
+
return runSessionOperation({ op: 'rename', sessionId: id }, () => withRecordLock(id, async () => {
|
|
882
|
+
const wt = await findWorktree(id)
|
|
883
|
+
if (!wt) return false
|
|
884
|
+
writeRecord({ ...wt.rec, name: name.trim() || null })
|
|
885
|
+
return true
|
|
886
|
+
}))
|
|
639
887
|
}
|
|
640
888
|
|
|
641
889
|
// @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
|
|
@@ -643,15 +891,20 @@ export async function renameSession(id: string, name: string): Promise<boolean>
|
|
|
643
891
|
// shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
|
|
644
892
|
// its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
|
|
645
893
|
export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
894
|
+
return runSessionOperation({ op: 'sort', sessionId: id }, () => withRecordLock(id, async () => {
|
|
895
|
+
const wt = await findWorktree(id)
|
|
896
|
+
if (!wt) return false
|
|
897
|
+
writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
|
|
898
|
+
return true
|
|
899
|
+
}))
|
|
650
900
|
}
|
|
651
901
|
|
|
652
|
-
// the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded.
|
|
902
|
+
// the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
|
|
903
|
+
// cannot read simply has no prompt to report — a READ accessor must not turn an unreadable record into an
|
|
904
|
+
// error for the surface asking about it; the row itself already carries the diagnosis.
|
|
653
905
|
export async function sessionPrompt(id: string): Promise<string | null> {
|
|
654
|
-
return readRecord(id) ? readPromptFile(id) : null
|
|
906
|
+
try { return readRecord(id) ? readPromptFile(id) : null }
|
|
907
|
+
catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
|
|
655
908
|
}
|
|
656
909
|
|
|
657
910
|
// @@@ lastKnownSession - the last successfully-read Session row per session_id. The record's EXISTENCE in
|
|
@@ -665,19 +918,90 @@ const lastKnownSession = new Map<string, Session>()
|
|
|
665
918
|
// old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
|
|
666
919
|
// non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
|
|
667
920
|
// Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
|
|
668
|
-
export async function listSessions(): Promise<Session[]> {
|
|
921
|
+
export async function listSessions(includeArchived = false): Promise<Session[]> {
|
|
669
922
|
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
670
923
|
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
671
924
|
const [ids, snap] = await Promise.all([
|
|
672
925
|
Promise.resolve(listSessionIds()), liveSnapshot(),
|
|
673
926
|
])
|
|
927
|
+
// Freeze one record snapshot for both the census join and row projection. A second full read after an awaited
|
|
928
|
+
// probe could pair record A with thread identity B and accidentally treat a missing census entry as clean.
|
|
929
|
+
const snapshots = new Map<string, { entry: PublicRecordEntry; rec: SessRec | null }>()
|
|
930
|
+
for (const id of ids) {
|
|
931
|
+
try {
|
|
932
|
+
const entry = readPublicRecordEntry(id)
|
|
933
|
+
snapshots.set(id, { entry, rec: entry.kind === 'ok' ? fromRaw(entry.raw) : null })
|
|
934
|
+
} catch { /* guardSession below preserves the last-known row for a transient read failure */ }
|
|
935
|
+
}
|
|
936
|
+
// Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
|
|
937
|
+
// control-plane probes; resources still owns the full turn/read probe for its detailed report.
|
|
938
|
+
const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
|
|
939
|
+
? [{ ...rec, harness: rec.harness || defaultHarness.id }]
|
|
940
|
+
: [])
|
|
941
|
+
const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map()
|
|
942
|
+
// A record can change while the one census is in flight. Mark such rows conservatively; never let the stale
|
|
943
|
+
// proof hide them. This is a bounded storage read, not another adapter RPC.
|
|
944
|
+
const changedDuringCensus = new Set<string>()
|
|
945
|
+
for (const rec of censusRecords) {
|
|
946
|
+
try {
|
|
947
|
+
const current = readPublicRecordEntry(rec.session)
|
|
948
|
+
const before = snapshots.get(rec.session)?.entry
|
|
949
|
+
if (current.kind !== 'ok' || before?.kind !== 'ok' || JSON.stringify(current.raw) !== JSON.stringify(before.raw)) changedDuringCensus.add(rec.session)
|
|
950
|
+
} catch { changedDuringCensus.add(rec.session) }
|
|
951
|
+
}
|
|
674
952
|
const rows = ids.map((id) => guardSession(id, () => {
|
|
675
|
-
|
|
953
|
+
// a record we cannot READ still has a row: it is a session that exists and whose state is unknowable, which
|
|
954
|
+
// is a thing to act on, not a thing to hide. It carries its own status and names the file, so the human can
|
|
955
|
+
// see the file and close it — the alternative (dropping it) is what made a live session read as gone.
|
|
956
|
+
const snapshot = snapshots.get(id)
|
|
957
|
+
if (!snapshot) throw new Error(`session ${id} record snapshot unavailable`)
|
|
958
|
+
const { entry } = snapshot
|
|
959
|
+
// The corrupt row becomes the LAST-KNOWN row, never a deletion. Dropping it would mean the next poll that
|
|
960
|
+
// hits a transient read failure has nothing to fall back on and the row vanishes — re-opening the exact
|
|
961
|
+
// hole this branch closes, one poll later. `corrupt` is a true reading, so it is worth remembering.
|
|
962
|
+
if (entry.kind === 'corrupt') { const c = corruptSession(id, entry); lastKnownSession.set(id, c); return c }
|
|
963
|
+
const rec = snapshot.rec
|
|
676
964
|
if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
|
|
965
|
+
// A forced public liveness comes only from the shared record projection. Do not let live process/thread
|
|
966
|
+
// evidence punch through it (including archive hazard repair).
|
|
967
|
+
if (entry.kind === 'ok' && entry.liveness === 'offline') {
|
|
968
|
+
const pending = toSession(rec, 'offline', 'offline')
|
|
969
|
+
lastKnownSession.set(id, pending)
|
|
970
|
+
return pending
|
|
971
|
+
}
|
|
677
972
|
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
678
973
|
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
679
974
|
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id))
|
|
680
|
-
const
|
|
975
|
+
const sessionHarness = harnessById(rec.harness || defaultHarness.id)
|
|
976
|
+
const resident = rec.harnessSessionId
|
|
977
|
+
? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
|
|
978
|
+
: undefined
|
|
979
|
+
const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length
|
|
980
|
+
const physical = rec.archived
|
|
981
|
+
? (sessionHarness.runtimeOwnership === 'adapter'
|
|
982
|
+
? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
|
|
983
|
+
: liveness({ ...rec, archived: false, stopped: false }, snap))
|
|
984
|
+
: null
|
|
985
|
+
// Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
|
|
986
|
+
// as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
|
|
987
|
+
// missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
|
|
988
|
+
// unloaded, so it remains visible until an explicit archive repair.
|
|
989
|
+
const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
|
|
990
|
+
const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
|
|
991
|
+
const projectedLv = projected === rec ? liveness(rec, snap) : physical!
|
|
992
|
+
const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
|
|
993
|
+
if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
|
|
994
|
+
? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
|
|
995
|
+
: hasValidColdProof(rec)
|
|
996
|
+
? residentRequired && !resident
|
|
997
|
+
? 'archived runtime hazard: adapter resident-reference census missing; exact unload is unproven'
|
|
998
|
+
: resident && !resident.healthy
|
|
999
|
+
? `archived runtime hazard: adapter resident-reference census is unknown (${resident.error || 'probe failed'})`
|
|
1000
|
+
: resident?.loaded
|
|
1001
|
+
? 'archived runtime hazard: target adapter thread is still loaded'
|
|
1002
|
+
: `archived runtime hazard: record says archived but physical liveness is ${physical}`
|
|
1003
|
+
: 'archived runtime hazard: record has no durable cold witness; exact adapter unload is unproven'
|
|
1004
|
+
if (rec.adapterRecovery) s.archiveHazard = `archive adapter recovery required: ${rec.adapterRecovery}`
|
|
681
1005
|
lastKnownSession.set(id, s)
|
|
682
1006
|
return s
|
|
683
1007
|
}, () => {
|
|
@@ -694,7 +1018,8 @@ export async function listSessions(): Promise<Session[]> {
|
|
|
694
1018
|
// `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
|
|
695
1019
|
// overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
|
|
696
1020
|
// same-instant births (or sort-keys) stay deterministic.
|
|
697
|
-
return rows.filter((s): s is Session => s != null
|
|
1021
|
+
return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
|
|
1022
|
+
.sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
|
|
698
1023
|
}
|
|
699
1024
|
|
|
700
1025
|
// a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
|
|
@@ -1063,10 +1388,42 @@ export function launcherCmd(rec: SessRec): string | undefined {
|
|
|
1063
1388
|
if (rec.launchCmd) return rec.launchCmd
|
|
1064
1389
|
return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
|
|
1065
1390
|
}
|
|
1391
|
+
// @@@ launch preflight - the launch transport's OWN settled failures, checked before a tmux window is ever
|
|
1392
|
+
// opened. Each is a fact about this machine right now that no number of attempts can change: the worktree the
|
|
1393
|
+
// agent would run in, the branch it would commit to, the command that would start it. Answering them here is
|
|
1394
|
+
// what turns a certain failure into ONE loud, named refusal instead of a launch that fast-exits and is retried
|
|
1395
|
+
// on a wall clock. Everything the transport CANNOT settle (a launcher that races its own daemon) still reaches
|
|
1396
|
+
// the bounded retry, and the harness's own settled failures are the adapter's to name (fatalLaunchOutput).
|
|
1397
|
+
export type LaunchBlock = { code: 'no-worktree' | 'no-branch' | 'no-launcher'; message: string }
|
|
1398
|
+
// does this ref resolve to a commit? Through git.ts's git() so a hook's exported GIT_DIR can't misdirect
|
|
1399
|
+
// discovery. `--verify --quiet` answers a MISSING ref with a bare non-zero exit and no stderr, while a broken
|
|
1400
|
+
// repo/timeout writes stderr — so only the silent failure is read as absence. A probe that could not answer
|
|
1401
|
+
// reads as "exists": the preflight refuses on a PROVEN absence, never on a failed probe ([[state]]'s board
|
|
1402
|
+
// honesty rule applied to launch).
|
|
1403
|
+
function refExists(cwd: string, ref: string): boolean {
|
|
1404
|
+
try { return !!git(['-C', cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() }
|
|
1405
|
+
catch (e) { return String((e as { stderr?: string })?.stderr ?? '').trim() !== '' }
|
|
1406
|
+
}
|
|
1407
|
+
export function launchPreflight(rec: SessRec): LaunchBlock | null {
|
|
1408
|
+
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
1409
|
+
return { code: 'no-worktree', message: `session ${rec.session.slice(0, 8)}: its worktree ${rec.worktreePath || '(unrecorded)'} does not exist — there is nothing to launch in. If the work merged and the worktree was removed, close the session; otherwise restore the worktree first.` }
|
|
1410
|
+
if (rec.branch && !refExists(rec.worktreePath, rec.branch))
|
|
1411
|
+
return { code: 'no-branch', message: `session ${rec.session.slice(0, 8)}: its branch ${rec.branch} no longer exists — a relaunch would put the agent on a detached or wrong ref. Restore the branch, or close the session.` }
|
|
1412
|
+
let cmd: string | undefined
|
|
1413
|
+
try { cmd = launcherCmd(rec) }
|
|
1414
|
+
catch (e) { return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its launcher cannot be resolved — ${e instanceof Error ? e.message : e}` } }
|
|
1415
|
+
// only an ABSOLUTE command is checkable here; a bare name is the shell's PATH lookup at launch time, and
|
|
1416
|
+
// guessing at it would refuse launches that work. Certainty is the whole point of a preflight.
|
|
1417
|
+
const bin = (cmd ?? '').trim().split(/\s+/)[0]
|
|
1418
|
+
if (bin && isAbsolute(bin) && !existsSync(bin))
|
|
1419
|
+
return { code: 'no-launcher', message: `session ${rec.session.slice(0, 8)}: its pinned launcher command ${bin} is not on this machine — every launch of it will fail until the path is restored or the session is re-dispatched under a launcher that exists.` }
|
|
1420
|
+
return null
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1066
1423
|
// @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
|
|
1067
1424
|
// invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
|
|
1068
1425
|
const shq1 = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
1069
|
-
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
1426
|
+
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): string {
|
|
1070
1427
|
const file = join(storeDir(id), 'launch.sh')
|
|
1071
1428
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
1072
1429
|
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
@@ -1094,24 +1451,65 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1094
1451
|
// A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
|
|
1095
1452
|
// app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
|
|
1096
1453
|
// retry loop is a runtime capability rather than a harness-id branch.
|
|
1454
|
+
// @@@ retry only what retrying can fix - a fast exit says the launcher stopped before readiness, which is
|
|
1455
|
+
// reason enough to try again but never a diagnosis. So after a fast exit the script reads what the harness
|
|
1456
|
+
// actually SAID and matches it against the ADAPTER's own settled-failure patterns ([[harness-adapter]]
|
|
1457
|
+
// fatalLaunchOutput). A match means this command cannot succeed however many times we run it: stop at one
|
|
1458
|
+
// attempt and let the harness's own line be the last thing on the pane, instead of spending a certain failure
|
|
1459
|
+
// three times and burying the reason. No match keeps the plain bounded retry.
|
|
1460
|
+
//
|
|
1461
|
+
// It reads the PANE, not the agent's streams. Capturing stderr through a pipe missed the answer entirely —
|
|
1462
|
+
// measured against real reclaude, "No conversation found with session ID" arrives on STDOUT, so a
|
|
1463
|
+
// stderr-only capture classified nothing and retried a certain failure three times (the unit test passed
|
|
1464
|
+
// only because its stub printed to the stream the implementation happened to watch). Redirecting stdout too
|
|
1465
|
+
// would be worse: a TUI that finds stdout is not a terminal stops being a TUI. The pane already holds both
|
|
1466
|
+
// streams exactly as the human sees them, and the script runs inside that pane — so it just asks tmux.
|
|
1467
|
+
const fatal = (harness.fatalLaunchOutput ?? []).join('|')
|
|
1097
1468
|
const launchBody = harness.launchOneShot ? [born, ''] : [
|
|
1098
1469
|
`for __spex_try in 1 2 3; do`,
|
|
1099
1470
|
` __spex_t0=$SECONDS`,
|
|
1471
|
+
// @@@ classify THIS attempt only - the pane is a scrollback, so it also holds every earlier attempt and
|
|
1472
|
+
// every earlier launch that ever ran in this window. Matching the whole capture would let a stale
|
|
1473
|
+
// settled-failure line from minutes ago condemn an unrelated fast exit and cut a launch that retrying
|
|
1474
|
+
// WOULD have recovered — the exact mirror of the miss this classifier exists to fix. So each attempt
|
|
1475
|
+
// stamps a line unique to (this run, this attempt) and the match starts after it. The run's pid is what
|
|
1476
|
+
// makes it unique across relaunches, which reuse the session id.
|
|
1477
|
+
` __spex_mark="attempt $__spex_try start $$"`,
|
|
1478
|
+
` printf '[spex launch] %s\\n' "$__spex_mark"`,
|
|
1100
1479
|
` ${born}`,
|
|
1101
1480
|
` __spex_rc=$?`,
|
|
1102
1481
|
` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
|
|
1482
|
+
...(fatal ? [
|
|
1483
|
+
// -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
|
|
1484
|
+
// can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
|
|
1485
|
+
// bounded retry stands.
|
|
1486
|
+
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shq1(fatal)}; then`,
|
|
1487
|
+
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1488
|
+
` exit $__spex_rc`,
|
|
1489
|
+
` fi`,
|
|
1490
|
+
] : []),
|
|
1103
1491
|
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1104
1492
|
` sleep 2`,
|
|
1105
1493
|
`done`,
|
|
1106
1494
|
`exit $__spex_rc`,
|
|
1107
1495
|
``,
|
|
1108
1496
|
]
|
|
1109
|
-
|
|
1497
|
+
const delegatePreamble = delegateFifo ? [
|
|
1498
|
+
`exec 9<${shq1(delegateFifo)}`,
|
|
1499
|
+
`rm -f ${shq1(delegateFifo)}`,
|
|
1500
|
+
'export SPEXCODE_MAINTENANCE_DELEGATE_FD=9',
|
|
1501
|
+
`export SPEXCODE_MAINTENANCE_SESSION_ID=${shq1(id)}`,
|
|
1502
|
+
] : []
|
|
1503
|
+
writeFileSync(file, [...delegatePreamble, ...launchBody].join('\n'))
|
|
1110
1504
|
return file
|
|
1111
1505
|
}
|
|
1112
|
-
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
1506
|
+
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): Promise<void> {
|
|
1507
|
+
// record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
|
|
1508
|
+
// into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
|
|
1509
|
+
// reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
|
|
1510
|
+
if (harness.ownsRendezvous) stampRvSock(id)
|
|
1113
1511
|
await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
|
|
1114
|
-
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
|
|
1512
|
+
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd, delegateFifo)}`])
|
|
1115
1513
|
await tmux(['send-keys', '-t', id, 'Enter'])
|
|
1116
1514
|
launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
1117
1515
|
}
|
|
@@ -1140,16 +1538,46 @@ function isOccupying(s: Session, snap: LiveSnap): boolean {
|
|
|
1140
1538
|
// In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
|
|
1141
1539
|
// the durable `queued` worktrees fresh with nothing in flight.
|
|
1142
1540
|
const launching = new Set<string>()
|
|
1541
|
+
// A queued launch and a cold archive must not cross between their final read and record write. This is a
|
|
1542
|
+
// narrow per-session intent latch, not a second cleanup primitive; the launch path simply leaves an archiving id
|
|
1543
|
+
// alone and the archive path re-probes before filing.
|
|
1544
|
+
const archiving = new Set<string>()
|
|
1545
|
+
const transitionTails = new Map<string, Promise<void>>()
|
|
1546
|
+
async function withSessionTransition<T>(id: string, body: () => Promise<T>): Promise<T> {
|
|
1547
|
+
const previous = transitionTails.get(id) ?? Promise.resolve()
|
|
1548
|
+
let release!: () => void
|
|
1549
|
+
const current = new Promise<void>((resolve) => { release = resolve })
|
|
1550
|
+
transitionTails.set(id, current)
|
|
1551
|
+
await previous
|
|
1552
|
+
try { return await body() }
|
|
1553
|
+
finally {
|
|
1554
|
+
release()
|
|
1555
|
+
if (transitionTails.get(id) === current) transitionTails.delete(id)
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1143
1558
|
let draining = false // re-entrancy guard: only one drain pass runs at a time (no double-launch)
|
|
1144
1559
|
|
|
1145
1560
|
// launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
|
|
1146
1561
|
// (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
|
|
1147
|
-
async function
|
|
1562
|
+
async function startQueuedUnlocked(id: string): Promise<boolean> {
|
|
1563
|
+
if (archiving.has(id)) return false
|
|
1148
1564
|
const wt = await findWorktree(id)
|
|
1149
1565
|
if (!wt) return false
|
|
1566
|
+
if (archiving.has(id) || wt.rec.archived) return false
|
|
1150
1567
|
if (!canDrainQueued(wt.rec)) return false
|
|
1151
1568
|
const launchPrompt = readLaunchFile(id)
|
|
1152
1569
|
if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
|
|
1570
|
+
// a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
|
|
1571
|
+
// a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
|
|
1572
|
+
// on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
|
|
1573
|
+
const blocked = launchPreflight(wt.rec)
|
|
1574
|
+
if (blocked) {
|
|
1575
|
+
if (wt.rec.note !== blocked.message) {
|
|
1576
|
+
console.error(`spex: not launching queued session ${id}: ${blocked.message}`)
|
|
1577
|
+
writeRecord({ ...wt.rec, note: blocked.message })
|
|
1578
|
+
}
|
|
1579
|
+
return false
|
|
1580
|
+
}
|
|
1153
1581
|
launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
1154
1582
|
const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
|
|
1155
1583
|
try {
|
|
@@ -1166,13 +1594,14 @@ async function startQueued(id: string): Promise<boolean> {
|
|
|
1166
1594
|
void waitForReady(id, h).finally(() => launching.delete(id))
|
|
1167
1595
|
return true
|
|
1168
1596
|
}
|
|
1597
|
+
const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
|
|
1169
1598
|
|
|
1170
1599
|
// @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
|
|
1171
1600
|
// re-entrancy-guarded; safe to call on every slot-freeing event (newSession / close / propose) AND on a
|
|
1172
1601
|
// periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
|
|
1173
1602
|
// (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
|
|
1174
1603
|
// so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
|
|
1175
|
-
|
|
1604
|
+
async function drainQueueUnlocked(): Promise<void> {
|
|
1176
1605
|
if (draining) return
|
|
1177
1606
|
draining = true
|
|
1178
1607
|
try {
|
|
@@ -1197,6 +1626,14 @@ export async function drainQueue(): Promise<void> {
|
|
|
1197
1626
|
}
|
|
1198
1627
|
} finally { draining = false }
|
|
1199
1628
|
}
|
|
1629
|
+
export const drainQueue = (): Promise<void> => runSessionOperation({ op: 'queue-drain' }, drainQueueUnlocked)
|
|
1630
|
+
const requestQueueDrain = (): void => {
|
|
1631
|
+
void drainQueue().catch((error) => {
|
|
1632
|
+
// An exact maintenance stop deliberately frees no unrelated queue work while admission is closed.
|
|
1633
|
+
if (error instanceof SessionMaintenanceError && error.code === 'maintenance_active') return
|
|
1634
|
+
console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
1635
|
+
})
|
|
1636
|
+
}
|
|
1200
1637
|
|
|
1201
1638
|
// @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
|
|
1202
1639
|
// newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
|
|
@@ -1264,11 +1701,14 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
|
|
|
1264
1701
|
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1265
1702
|
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1266
1703
|
const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1704
|
+
return runSessionOperation({ op: 'create' }, async () => {
|
|
1705
|
+
try {
|
|
1706
|
+
return { status: 201, session: await create(prompt, parent, launcher) }
|
|
1707
|
+
} catch (e) {
|
|
1708
|
+
if (e instanceof SessionMaintenanceError) throw e
|
|
1709
|
+
return { status: 400, error: String((e as Error).message || e) }
|
|
1710
|
+
}
|
|
1711
|
+
})
|
|
1272
1712
|
}
|
|
1273
1713
|
|
|
1274
1714
|
// @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
|
|
@@ -1280,6 +1720,9 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
|
|
|
1280
1720
|
// reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
|
|
1281
1721
|
// handler calls newSession directly, so it never re-enters this path.
|
|
1282
1722
|
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1723
|
+
if (maintenanceBrokerDescriptors()) {
|
|
1724
|
+
throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
|
|
1725
|
+
}
|
|
1283
1726
|
await assertProjectMatch('spex session new')
|
|
1284
1727
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
1285
1728
|
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
@@ -1295,7 +1738,7 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
1295
1738
|
})
|
|
1296
1739
|
} catch {
|
|
1297
1740
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1298
|
-
return newSession(prompt, parent, launcher)
|
|
1741
|
+
return runSessionOperation({ op: 'fallback-create' }, () => newSession(prompt, parent, launcher))
|
|
1299
1742
|
}
|
|
1300
1743
|
if (!res.ok) {
|
|
1301
1744
|
const text = await res.text().catch(() => '')
|
|
@@ -1308,6 +1751,23 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
1308
1751
|
return await res.json() as Session
|
|
1309
1752
|
}
|
|
1310
1753
|
|
|
1754
|
+
// @@@ spawnerClause - where the SPAWNER works, told to the child ([[spawner-pointer]]). A child's worktree is
|
|
1755
|
+
// branched off the BASE branch, never off its spawner, so everything that session has in flight — a spec node
|
|
1756
|
+
// it just created, an edit it hasn't landed — is absent from the child's tree AND from the spec index the
|
|
1757
|
+
// pointer above resolves against (that index reads the backend's own checkout). Teaching the fork or the
|
|
1758
|
+
// landing about nesting would cost a second base per session and would carry the spawner's unreviewed commits
|
|
1759
|
+
// into whatever the child merges into; naming the spawner's worktree costs one line and lets the agent decide.
|
|
1760
|
+
// A POINTER, never a body — same family rule as [[spec-pointer]] — and fail-quiet by absence: no parent, or a
|
|
1761
|
+
// parent record without a worktree, appends nothing.
|
|
1762
|
+
export function spawnerClause(p: SessRec | null): string {
|
|
1763
|
+
if (!p?.worktreePath) return ''
|
|
1764
|
+
const who = p.name || p.title
|
|
1765
|
+
return `\n\nYou were created by session \`${p.session.slice(0, 8)}\`${who ? ` (${who})` : ''}, whose worktree is ${p.worktreePath}` +
|
|
1766
|
+
`${p.branch ? ` on branch \`${p.branch}\`` : ''}. Your own worktree is branched from \`${mainBranch()}\`, so it does NOT contain that ` +
|
|
1767
|
+
`session's uncommitted or unmerged work — a spec node it just created, an edit it hasn't landed. If your task needs anything of theirs, ` +
|
|
1768
|
+
`read it there directly. Read only: never write into another session's worktree.`
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1311
1771
|
// @@@ newSession - durable worktree (branch node/<slug> off main) + a global session.json record. The agent does NOT
|
|
1312
1772
|
// launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
|
|
1313
1773
|
// immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
|
|
@@ -1334,14 +1794,13 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1334
1794
|
const branch = `node/${slug}`
|
|
1335
1795
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1336
1796
|
// Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
|
|
1337
|
-
// preset resolution breaks. The optional spec
|
|
1797
|
+
// preset resolution breaks. The optional spec + spawner pointers are seam inputs; the note insert remains last.
|
|
1338
1798
|
const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
|
|
1339
|
-
const suffix = spec
|
|
1340
|
-
|
|
1341
|
-
: undefined
|
|
1799
|
+
const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
|
|
1800
|
+
+ spawnerClause(parent ? readRecord(parent) : null)
|
|
1342
1801
|
const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
|
|
1343
1802
|
loadedSpecs: launchSpecs ?? undefined,
|
|
1344
|
-
suffix,
|
|
1803
|
+
suffix: suffix || undefined,
|
|
1345
1804
|
})).text
|
|
1346
1805
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1347
1806
|
// the checkout delivers the tracked spec sources and the materialize below delivers the materialized
|
|
@@ -1360,7 +1819,7 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1360
1819
|
// mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
|
|
1361
1820
|
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
1362
1821
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
1363
|
-
harness: h.id, harnessSessionId: null, launcher: chosen.name,
|
|
1822
|
+
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
1364
1823
|
// PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
|
|
1365
1824
|
// (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
|
|
1366
1825
|
// re-resolving against a default that may have flipped (a backend restarted under a different launcher).
|
|
@@ -1378,12 +1837,11 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1378
1837
|
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
1379
1838
|
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
1380
1839
|
const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
|
|
1381
|
-
// Every adapter
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
const recordOnline = h.liveness(after, false, runtimeRoot()) === 'online'
|
|
1840
|
+
// Every headless adapter is record-backed and therefore online immediately; interactive adapters still need
|
|
1841
|
+
// their real process/transport snapshot. Read that capability directly instead of smuggling a harness-mode
|
|
1842
|
+
// probe through liveness with fake tmuxAlive=false.
|
|
1385
1843
|
const queued = after.status === 'queued'
|
|
1386
|
-
return toSession(after, queued ? 'queued' : 'working',
|
|
1844
|
+
return toSession(after, queued ? 'queued' : 'working', h.headless ? 'online' : queued ? 'offline' : 'starting')
|
|
1387
1845
|
}
|
|
1388
1846
|
|
|
1389
1847
|
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
@@ -1417,13 +1875,29 @@ const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fai
|
|
|
1417
1875
|
// waitForReady (slot-hold + resume) waits through a daemon-race retry
|
|
1418
1876
|
// instead of returning before a recovering socket
|
|
1419
1877
|
const SOCKET_POLL_MS = 200
|
|
1420
|
-
async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<
|
|
1878
|
+
async function waitForReady(id: string, harness: Harness, pending?: SessRec, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<HarnessLaunchReadinessFence | null> {
|
|
1879
|
+
const current = () => {
|
|
1880
|
+
const stored = readRecord(id)
|
|
1881
|
+
const rec = stored && pending
|
|
1882
|
+
? { ...pending, ...stored, stopped: pending.stopped, archived: pending.archived }
|
|
1883
|
+
: stored || pending
|
|
1884
|
+
return rec ? { ...rec, runtimeDir: runtimeRoot() } : null
|
|
1885
|
+
}
|
|
1421
1886
|
const deadline = Date.now() + timeoutMs
|
|
1887
|
+
if (harness.launchReady) return harness.launchReady(current, deadline)
|
|
1888
|
+
const genericFence = (): HarnessLaunchReadinessFence => ({
|
|
1889
|
+
proof: Object.freeze({ kind: 'adapter-liveness', harnessId: harness.id, sessionId: id }),
|
|
1890
|
+
validate: async (latest) => {
|
|
1891
|
+
const rec = latest()
|
|
1892
|
+
const snap = await liveSnapshot()
|
|
1893
|
+
return !!rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online'
|
|
1894
|
+
},
|
|
1895
|
+
})
|
|
1422
1896
|
for (;;) {
|
|
1423
|
-
const rec =
|
|
1897
|
+
const rec = current()
|
|
1424
1898
|
const snap = await liveSnapshot() // window + pane probe + live-listener set in one snapshot — all the adapter needs
|
|
1425
|
-
if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return
|
|
1426
|
-
if (Date.now() >= deadline) return
|
|
1899
|
+
if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return genericFence()
|
|
1900
|
+
if (Date.now() >= deadline) return null
|
|
1427
1901
|
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
|
|
1428
1902
|
}
|
|
1429
1903
|
}
|
|
@@ -1450,26 +1924,165 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
|
|
|
1450
1924
|
// session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
|
|
1451
1925
|
// a refusal leaves the record wholly untouched.
|
|
1452
1926
|
// Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
1453
|
-
|
|
1927
|
+
type ResumeOptions = { force?: boolean; guard?: boolean; authorization?: Authorization }
|
|
1928
|
+
type ResumeExecutionOptions = ResumeOptions & { ticket?: MaintenanceTicket }
|
|
1929
|
+
|
|
1930
|
+
async function delegatedSpawnTransfer(id: string, ticket: MaintenanceTicket): Promise<{
|
|
1931
|
+
fifo: string
|
|
1932
|
+
done: Promise<void>
|
|
1933
|
+
close(): void
|
|
1934
|
+
}> {
|
|
1935
|
+
const fifo = join(storeDir(id), `.maintenance-delegate-${randomUUID()}.fifo`)
|
|
1936
|
+
await pexec('mkfifo', ['-m', '600', fifo])
|
|
1937
|
+
const bearer = ticket.delegateSharedSpawn(id)
|
|
1938
|
+
const writer = spawn('sh', ['-c', 'cat > "$1"', 'spex-maintenance-delegate', fifo], {
|
|
1939
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
1940
|
+
})
|
|
1941
|
+
let stderr = ''
|
|
1942
|
+
writer.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk })
|
|
1943
|
+
writer.stdin.on('error', () => {})
|
|
1944
|
+
writer.stdin.end(bearer)
|
|
1945
|
+
let settled = false
|
|
1946
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
1947
|
+
writer.once('error', (error) => { settled = true; reject(error) })
|
|
1948
|
+
writer.once('close', (code, signal) => {
|
|
1949
|
+
settled = true
|
|
1950
|
+
if (code === 0) resolve()
|
|
1951
|
+
else reject(new SessionMaintenanceError('maintenance_delegate_invalid',
|
|
1952
|
+
`shared-spawn delegate transfer failed (${signal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''})`,
|
|
1953
|
+
{ operation: 'shared-spawn', sessionId: id }))
|
|
1954
|
+
})
|
|
1955
|
+
})
|
|
1956
|
+
return {
|
|
1957
|
+
fifo,
|
|
1958
|
+
done,
|
|
1959
|
+
close() {
|
|
1960
|
+
if (!settled) writer.kill('SIGTERM')
|
|
1961
|
+
rmSync(fifo, { force: true })
|
|
1962
|
+
},
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
1454
1967
|
const { force = false, guard = true } = opts
|
|
1455
|
-
|
|
1968
|
+
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
1969
|
+
try { wt = await findWorktree(id) }
|
|
1970
|
+
catch (e) { if (e instanceof SessionRecordUnusable) return { ok: false, refused: true, error: e.message }; throw e }
|
|
1456
1971
|
if (!wt) return { ok: false, error: `no such session ${id}` }
|
|
1972
|
+
// A process that died while validating left an internal candidate behind. This record lock proves no live
|
|
1973
|
+
// resume still owns it. Restore the frozen public original before doing any transport work and require an
|
|
1974
|
+
// explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
|
|
1975
|
+
if (wt.rec.launchReadinessPending) {
|
|
1976
|
+
writeRecord(restoreLaunchReadinessOriginal(wt.rec))
|
|
1977
|
+
return {
|
|
1978
|
+
ok: false,
|
|
1979
|
+
refused: true,
|
|
1980
|
+
error: `session ${id}: stale launch readiness pending was recovered fail-closed; the exact stopped/offline record was retained. Retry resume.`,
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const preResume = wt.rec
|
|
1984
|
+
// a retired session (its worktree gone) is terminal, not offline: say so in its own words rather than in the
|
|
1985
|
+
// preflight's, since `close` — not a repair — is what it needs.
|
|
1986
|
+
const retired = retirementReason(wt.rec)
|
|
1987
|
+
if (retired) return { ok: false, refused: true, error: retired }
|
|
1988
|
+
// everything else the transport can settle before opening a window: no branch, no launcher. A launch that
|
|
1989
|
+
// cannot succeed must not be attempted, retried, or given a regenerated launch script.
|
|
1990
|
+
const blocked = launchPreflight(wt.rec)
|
|
1991
|
+
if (blocked) return { ok: false, refused: true, error: blocked.message }
|
|
1457
1992
|
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
1458
|
-
|
|
1993
|
+
// An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
|
|
1994
|
+
// it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
|
|
1995
|
+
// resume can never kill a live leaf merely because the record was hidden.
|
|
1996
|
+
const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec
|
|
1997
|
+
const resumeSnap = await liveSnapshot()
|
|
1998
|
+
const lv = h.runtimeOwnership === 'adapter'
|
|
1999
|
+
? (resumeSnap.windows.has(id) ? 'online' : 'offline')
|
|
2000
|
+
: liveness(probeRec, resumeSnap) // FRESH, honest liveness (listener-verified)
|
|
1459
2001
|
if (guard && !force && lv === 'online')
|
|
1460
2002
|
return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` }
|
|
1461
2003
|
if (guard && !force && lv === 'unknown')
|
|
1462
2004
|
return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` }
|
|
2005
|
+
const wasArchived = wt.rec.archived
|
|
2006
|
+
if (!wasArchived && wt.rec.adapterRecovery) {
|
|
2007
|
+
const recovery = await h.restoreRuntime?.(wt.rec)
|
|
2008
|
+
if (recovery && !recovery.ok) return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` }
|
|
2009
|
+
writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true })
|
|
2010
|
+
wt = await findWorktree(id)
|
|
2011
|
+
if (!wt) return { ok: false, error: `session ${id} disappeared during adapter recovery` }
|
|
2012
|
+
}
|
|
2013
|
+
if (wasArchived && (force || lv === 'offline')) {
|
|
2014
|
+
// Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
|
|
2015
|
+
// a retryable unarchived record rather than archived:true with a newly loaded target thread.
|
|
2016
|
+
const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending'
|
|
2017
|
+
writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery })
|
|
2018
|
+
const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery }
|
|
2019
|
+
const restored = await h.restoreRuntime?.(visible)
|
|
2020
|
+
if (restored && !restored.ok) return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` }
|
|
2021
|
+
writeRecord({ ...(readRecord(id) || visible), adapterRecovery: null, coldProof: null })
|
|
2022
|
+
}
|
|
1463
2023
|
// proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
|
|
1464
|
-
// is CONFIRMED offline (or force — the wedged-but-alive escape).
|
|
1465
|
-
|
|
2024
|
+
// is CONFIRMED offline (or force — the wedged-but-alive escape). Clear the explicit-stop marker only after
|
|
2025
|
+
// launch has accepted the relaunch; a thrown launch leaves the record truthfully stopped. `starting`/`unknown`
|
|
2026
|
+
// fall through to a metadata-only no-op.
|
|
2027
|
+
// Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
|
|
2028
|
+
// starting -> online launch path recreates the same conversation.
|
|
2029
|
+
const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec
|
|
2030
|
+
const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
|
|
1466
2031
|
if (force || lv === 'offline') {
|
|
1467
2032
|
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2033
|
+
const transfer = opts.authorization && opts.ticket && h.sharedRuntimeSpawn
|
|
2034
|
+
? await delegatedSpawnTransfer(id, opts.ticket)
|
|
2035
|
+
: null
|
|
2036
|
+
try {
|
|
2037
|
+
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec), transfer?.fifo)
|
|
2038
|
+
if (transfer) await transfer.done
|
|
2039
|
+
} finally { transfer?.close() }
|
|
2040
|
+
let readiness: HarnessLaunchReadinessFence | null = null
|
|
2041
|
+
let readinessError = ''
|
|
2042
|
+
try { readiness = await waitForReady(id, h, resumed) }
|
|
2043
|
+
catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
|
|
2044
|
+
if (!readiness) {
|
|
2045
|
+
const failed = readRecord(id) || current
|
|
2046
|
+
writeRecord({ ...failed, ...preResume, launchReadinessPending: null })
|
|
2047
|
+
return {
|
|
2048
|
+
ok: false,
|
|
2049
|
+
refused: true,
|
|
2050
|
+
error: `session ${id}: launch did not become ready${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const latest = readRecord(id) || resumed
|
|
2054
|
+
const candidate: SessRec = {
|
|
2055
|
+
...latest,
|
|
2056
|
+
archived: false,
|
|
2057
|
+
coldProof: null,
|
|
2058
|
+
status: latest.status === 'active' ? 'idle' : latest.status,
|
|
2059
|
+
stopped: false,
|
|
2060
|
+
launchReadinessPending: launchReadinessPending(preResume),
|
|
2061
|
+
}
|
|
2062
|
+
writeRecord(candidate)
|
|
2063
|
+
let stillReady = false
|
|
2064
|
+
try { stillReady = await readiness.validate(() => {
|
|
2065
|
+
const stored = readRecord(id)
|
|
2066
|
+
return stored ? { ...stored, runtimeDir: runtimeRoot() } : null
|
|
2067
|
+
}) }
|
|
2068
|
+
catch (error) { readinessError = error instanceof Error ? error.message : String(error) }
|
|
2069
|
+
if (!stillReady) {
|
|
2070
|
+
const failed = readRecord(id) || candidate
|
|
2071
|
+
writeRecord(restoreLaunchReadinessOriginal(failed))
|
|
2072
|
+
return {
|
|
2073
|
+
ok: false,
|
|
2074
|
+
refused: true,
|
|
2075
|
+
error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
const published = readRecord(id) || candidate
|
|
2079
|
+
writeRecord({ ...published, launchReadinessPending: null })
|
|
2080
|
+
} else writeRecord(resumed)
|
|
1471
2081
|
return { ok: true }
|
|
1472
2082
|
}
|
|
2083
|
+
export const resumeSession = (id: string, opts: ResumeOptions = {}) =>
|
|
2084
|
+
runSessionOperation({ op: 'resume', sessionId: id, force: opts.force === true, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
|
|
2085
|
+
(ticket) => withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, { ...opts, ticket }))))
|
|
1473
2086
|
|
|
1474
2087
|
// @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
|
|
1475
2088
|
// authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
|
|
@@ -1479,14 +2092,16 @@ export async function resumeSession(id: string, opts: { force?: boolean; guard?:
|
|
|
1479
2092
|
export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
|
|
1480
2093
|
const id = opts.sessionId || ownSessionId()
|
|
1481
2094
|
if (!id) return false
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2095
|
+
return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
|
|
2096
|
+
const rec = readLiveRecord(id)
|
|
2097
|
+
if (!rec) return false
|
|
2098
|
+
writeRecord({
|
|
2099
|
+
...rec, status,
|
|
2100
|
+
proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
|
|
2101
|
+
note: opts.note ?? null,
|
|
2102
|
+
})
|
|
2103
|
+
return true
|
|
2104
|
+
}))
|
|
1490
2105
|
}
|
|
1491
2106
|
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
1492
2107
|
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
@@ -1495,19 +2110,23 @@ export const markError = (sessionId?: string) => markState('error', { sessionId
|
|
|
1495
2110
|
// a zero exit is never routed here, and a declaration that landed before teardown is authoritative.
|
|
1496
2111
|
export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
|
|
1497
2112
|
if (exitCode === '0') return false
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
2113
|
+
return runSessionOperationSync({ op: 'lifecycle-transition', sessionId }, () => withRecordLockSync(sessionId, () => {
|
|
2114
|
+
const rec = readLiveRecord(sessionId)
|
|
2115
|
+
if (!rec || rec.status !== 'active') return false
|
|
2116
|
+
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
|
|
2117
|
+
writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
|
|
2118
|
+
return true
|
|
2119
|
+
}))
|
|
1503
2120
|
}
|
|
1504
2121
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
1505
2122
|
const id = sessionId || ownSessionId()
|
|
1506
2123
|
if (!id || !harnessSessionId) return false
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
2124
|
+
return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
|
|
2125
|
+
const rec = readLiveRecord(id)
|
|
2126
|
+
if (!rec) return false
|
|
2127
|
+
writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
|
|
2128
|
+
return true
|
|
2129
|
+
}))
|
|
1511
2130
|
}
|
|
1512
2131
|
// @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
|
|
1513
2132
|
// active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
|
|
@@ -1517,10 +2136,12 @@ export function markHarnessSessionId(sessionId: string | undefined, harnessSessi
|
|
|
1517
2136
|
export function markIdle(sessionId?: string): boolean {
|
|
1518
2137
|
const id = sessionId || ownSessionId()
|
|
1519
2138
|
if (!id) return false
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
2139
|
+
return runSessionOperationSync({ op: 'lifecycle-transition', sessionId: id }, () => withRecordLockSync(id, () => {
|
|
2140
|
+
const rec = readLiveRecord(id)
|
|
2141
|
+
if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
|
|
2142
|
+
writeRecord({ ...rec, status: 'idle' })
|
|
2143
|
+
return true
|
|
2144
|
+
}))
|
|
1524
2145
|
}
|
|
1525
2146
|
// @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
|
|
1526
2147
|
// PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
|
|
@@ -1529,9 +2150,13 @@ export function markIdle(sessionId?: string): boolean {
|
|
|
1529
2150
|
// way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
|
|
1530
2151
|
|
|
1531
2152
|
// @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
|
|
1532
|
-
// done
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
2153
|
+
// done. The dogfood ritual lands every change as a COMMIT on the node branch first, so an uncommitted
|
|
2154
|
+
// working tree blocks EITHER proposal: the declaration claims the work is committed, and a dirty tree makes
|
|
2155
|
+
// that false. The second condition — 0 commits ahead of main — is checked ONLY for `merge`, because it is
|
|
2156
|
+
// the only one the claim contradicts: `--propose merge` asserts there is committed work to land, while
|
|
2157
|
+
// `--propose nothing` asserts the opposite ("committed, but I am NOT proposing a merge; paused for the human
|
|
2158
|
+
// to look"), which a lane whose work ALREADY landed states truthfully. Gating `nothing` on ahead-of-main
|
|
2159
|
+
// left such a lane one way through: an empty commit — a lie in git history to satisfy a check about honesty. Since the global-store refactor, SpexCode writes NO per-session files into
|
|
1535
2160
|
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
|
|
1536
2161
|
// hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
|
|
1537
2162
|
// neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
|
|
@@ -1539,7 +2164,7 @@ export function markIdle(sessionId?: string): boolean {
|
|
|
1539
2164
|
// Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
|
|
1540
2165
|
// can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
|
|
1541
2166
|
// refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
|
|
1542
|
-
export function mergeReadiness(): { ready: boolean; reason?: string } {
|
|
2167
|
+
export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
|
|
1543
2168
|
let dirty: string[] = []
|
|
1544
2169
|
try {
|
|
1545
2170
|
dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath)
|
|
@@ -1548,10 +2173,12 @@ export function mergeReadiness(): { ready: boolean; reason?: string } {
|
|
|
1548
2173
|
const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '')
|
|
1549
2174
|
return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` }
|
|
1550
2175
|
}
|
|
2176
|
+
// a `nothing` proposal makes no claim about having something to land, so the clean tree is the whole gate.
|
|
2177
|
+
if (proposal === 'nothing') return { ready: true }
|
|
1551
2178
|
let ahead = 0
|
|
1552
2179
|
const base = mainBranch()
|
|
1553
2180
|
try { ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0 } catch { ahead = 0 }
|
|
1554
|
-
if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge` }
|
|
2181
|
+
if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge (declaring \`done --propose nothing\` needs no commits ahead; use it to pause for the human)` }
|
|
1555
2182
|
return { ready: true }
|
|
1556
2183
|
}
|
|
1557
2184
|
|
|
@@ -1573,9 +2200,17 @@ function porcelainPath(line: string): string {
|
|
|
1573
2200
|
// real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
|
|
1574
2201
|
// the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
|
|
1575
2202
|
// has that id.
|
|
2203
|
+
// The measured-loss READOUT beside the git/graph gates. It grades nothing: no threshold, no pass/fail, no
|
|
2204
|
+
// block — the manager reads the four mutually exclusive scenario categories [[session-eval]] already folded
|
|
2205
|
+
// and decides. Its phase is part of the fact: a projection that has not been computed yet is NOT a clean
|
|
2206
|
+
// gate, so an absent/loading/updating/failed projection reports that phase and carries no numbers at all
|
|
2207
|
+
// rather than four honest-looking zeros.
|
|
2208
|
+
export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
|
|
2209
|
+
export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
|
|
1576
2210
|
export type ReviewGates = {
|
|
1577
2211
|
conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
|
|
1578
2212
|
lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
|
|
2213
|
+
evals: ReviewEvalGate // [[session-eval]]'s already-computed scenario categories
|
|
1579
2214
|
}
|
|
1580
2215
|
export type ReviewPayload = {
|
|
1581
2216
|
id: string; node: string | null; branch: string | null
|
|
@@ -1625,6 +2260,22 @@ async function lintGate(): Promise<ReviewGates['lint']> {
|
|
|
1625
2260
|
return p
|
|
1626
2261
|
}
|
|
1627
2262
|
|
|
2263
|
+
// @@@ evalGate - the cockpit's measured-loss readout, taken from [[session-eval]]'s EXISTING projection.
|
|
2264
|
+
// This is a cache READ and must stay one: buildSessionEvals() itself calls reviewPayload(), so building the
|
|
2265
|
+
// model from here would recurse. The import is dynamic for the same reason the lint gate's is — the eval
|
|
2266
|
+
// package imports this module, and the cockpit only needs it at call time.
|
|
2267
|
+
async function evalGate(id: string): Promise<ReviewEvalGate> {
|
|
2268
|
+
const { sessionEvalProjection } = await import('../../spec-eval/src/sessioneval.js')
|
|
2269
|
+
const projection = sessionEvalProjection(id)
|
|
2270
|
+
if (!projection) return { phase: 'unavailable' }
|
|
2271
|
+
// only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
|
|
2272
|
+
if (projection.phase !== 'ready' || !projection.value) {
|
|
2273
|
+
return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
|
|
2274
|
+
}
|
|
2275
|
+
const summary = projection.value
|
|
2276
|
+
return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
|
|
2277
|
+
}
|
|
2278
|
+
|
|
1628
2279
|
// @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
|
|
1629
2280
|
// (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
|
|
1630
2281
|
// in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
|
|
@@ -1634,12 +2285,13 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1634
2285
|
const wt = await findWorktree(id)
|
|
1635
2286
|
if (!wt) return null
|
|
1636
2287
|
const base = mainBranch()
|
|
1637
|
-
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
2288
|
+
const [aheadOut, statusOut, diff, conflictsWithMain, lint, evals] = await Promise.all([
|
|
1638
2289
|
gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
|
|
1639
2290
|
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
1640
2291
|
mergeBaseDiff(wt.path, base),
|
|
1641
2292
|
mergeConflicts(wt.path, base),
|
|
1642
2293
|
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
2294
|
+
evalGate(id), // measured loss — a READ of the existing projection, never a build
|
|
1643
2295
|
])
|
|
1644
2296
|
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
1645
2297
|
// path is genuine work — this is just the total uncommitted count.
|
|
@@ -1649,7 +2301,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1649
2301
|
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
1650
2302
|
ahead: Number(aheadOut.trim()) || 0,
|
|
1651
2303
|
dirtyNonRuntime, diff,
|
|
1652
|
-
gates: { conflictsWithMain, lint },
|
|
2304
|
+
gates: { conflictsWithMain, lint, evals },
|
|
1653
2305
|
proposal: { kind: wt.rec.proposal, note: wt.rec.note },
|
|
1654
2306
|
}
|
|
1655
2307
|
}
|
|
@@ -1658,16 +2310,21 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1658
2310
|
// server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
|
|
1659
2311
|
// outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
|
|
1660
2312
|
// merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
|
|
1661
|
-
// into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree)
|
|
2313
|
+
// into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree) —
|
|
2314
|
+
// and that checkout is the fleet's ONE landing door, so the prompt orders the landing to be TRIVIAL by the
|
|
2315
|
+
// time it gets there: sync + resolve in the agent's own worktree, land only when `merge-base --is-ancestor`
|
|
2316
|
+
// says the branch already contains the base, wait (never abort) on someone else's in-progress merge. The
|
|
2317
|
+
// always-on half of that contract is the `atomic-landing` system plugin; this prompt is the per-merge half.
|
|
1662
2318
|
// After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
|
|
1663
2319
|
// would block a merge proposal; propose-close is exempt) — and the human confirms the close.
|
|
1664
2320
|
function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
1665
2321
|
const base = mainBranch()
|
|
1666
|
-
return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself.\n\n` +
|
|
1667
|
-
`1.
|
|
1668
|
-
`2.
|
|
1669
|
-
`3.
|
|
1670
|
-
`4.
|
|
2322
|
+
return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself — in YOUR OWN worktree, never in the shared ${base} checkout.\n\n` +
|
|
2323
|
+
`1. Sync first, where you work: \`git merge ${base}\` INTO your branch, resolve every conflict here, and re-run what proves your work. The ${base} checkout is the fleet's ONE landing door — a merge that stops to ask about conflicts holds it for everyone.\n` +
|
|
2324
|
+
`2. Land only a TRIVIAL merge: \`git -C ${mainPath} merge-base --is-ancestor ${base} ${branch}\` must exit 0 (your branch already contains ${base}) — then\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n If that check fails, ${base} moved while you tested: go back to step 1 instead of landing.\n` +
|
|
2325
|
+
`3. A busy door is a wait, not a race: if the ${base} checkout is already mid-merge (an unresolved index), retry with a bounded wait — never abort or resolve someone else's in-progress merge. ` +
|
|
2326
|
+
`4. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if YOUR merge went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
|
|
2327
|
+
`5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
1671
2328
|
}
|
|
1672
2329
|
|
|
1673
2330
|
// @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
|
|
@@ -1679,7 +2336,7 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
|
1679
2336
|
// the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
|
|
1680
2337
|
// CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
|
|
1681
2338
|
// re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
|
|
1682
|
-
|
|
2339
|
+
async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
|
|
1683
2340
|
const wt = await findWorktree(id)
|
|
1684
2341
|
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
1685
2342
|
const branch = wt.branch, main = mainRoot()
|
|
@@ -1693,49 +2350,415 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
|
|
|
1693
2350
|
if (!r.ok) return { dispatched: false, reason: r.error }
|
|
1694
2351
|
return { dispatched: true }
|
|
1695
2352
|
}
|
|
2353
|
+
export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
|
|
2354
|
+
runSessionOperation({ op: 'merge-dispatch', sessionId: id }, () => mergeSessionUnlocked(id))
|
|
2355
|
+
|
|
2356
|
+
// @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
|
|
2357
|
+
// process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
|
|
2358
|
+
// tmux server and keep running, orphaned, still holding its rendezvous socket (measured: pane gone, server
|
|
2359
|
+
// gone, agent still answering). Close promises ZERO residue including the process tree, so the teardown
|
|
2360
|
+
// escalates on the pid launch registered for exactly this purpose: give the SIGHUP its moment, then SIGTERM,
|
|
2361
|
+
// then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
|
|
2362
|
+
// is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
|
|
2363
|
+
// The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
|
|
2364
|
+
// signal only a pid whose argv still names THIS session. Unidentifiable → we signal nothing and let the
|
|
2365
|
+
// adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
|
|
2366
|
+
const AGENT_EXIT_GRACE_MS = 3000
|
|
2367
|
+
type LeafIdentity = { pid: number; startToken: string; ownerNeedle: string }
|
|
2368
|
+
async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
|
|
2369
|
+
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2370
|
+
if (pid !== leaf.pid)
|
|
2371
|
+
throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
|
|
2372
|
+
if (!Number.isFinite(pid) || pid <= 0) return
|
|
2373
|
+
const startToken = leaf.startToken
|
|
2374
|
+
const alive = (): boolean => {
|
|
2375
|
+
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
2376
|
+
}
|
|
2377
|
+
const identityState = (): 'same' | 'gone' | 'changed' => {
|
|
2378
|
+
if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
|
|
2379
|
+
const current = processStartToken(pid)
|
|
2380
|
+
if (!current) return alive() ? 'changed' : 'gone'
|
|
2381
|
+
return current === startToken ? 'same' : 'changed'
|
|
2382
|
+
}
|
|
2383
|
+
const initialState = identityState()
|
|
2384
|
+
if (initialState === 'gone') return
|
|
2385
|
+
if (initialState === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
|
|
2386
|
+
const gone = async (ms: number): Promise<boolean> => {
|
|
2387
|
+
for (const end = Date.now() + ms; Date.now() < end;) {
|
|
2388
|
+
if (!alive()) return true
|
|
2389
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
2390
|
+
}
|
|
2391
|
+
return !alive()
|
|
2392
|
+
}
|
|
2393
|
+
if (await gone(AGENT_EXIT_GRACE_MS)) return // the pane's SIGHUP took it — the normal path
|
|
2394
|
+
const sameAgentInstance = async (): Promise<boolean> => {
|
|
2395
|
+
if (processStartToken(pid) !== startToken) return false
|
|
2396
|
+
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2397
|
+
return argv.includes(leaf.ownerNeedle) && processStartToken(pid) === startToken
|
|
2398
|
+
}
|
|
2399
|
+
for (const sig of ['SIGTERM', 'SIGKILL'] as const) {
|
|
2400
|
+
await beforeSignal()
|
|
2401
|
+
const state = identityState()
|
|
2402
|
+
if (state === 'gone') return
|
|
2403
|
+
if (state === 'changed') throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`)
|
|
2404
|
+
if (!await sameAgentInstance()) throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} no longer proves ownership`)
|
|
2405
|
+
try { process.kill(pid, sig) } catch { return } // vanished between checks
|
|
2406
|
+
if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000)) return
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
1696
2409
|
|
|
1697
2410
|
// @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
|
|
1698
|
-
// two: kill the agent's tmux client,
|
|
1699
|
-
// window reading `starting` instead of `offline`), and ask
|
|
1700
|
-
//
|
|
1701
|
-
|
|
1702
|
-
|
|
2411
|
+
// two: kill the agent's tmux client, make sure the agent itself actually went with it, drop its boot-window
|
|
2412
|
+
// stamp (else a just-launched id lingers in the grace window reading `starting` instead of `offline`), and ask
|
|
2413
|
+
// the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
|
|
2414
|
+
// removes a transport whose listener is PROVEN dead.
|
|
2415
|
+
// Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
2416
|
+
async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
|
|
2417
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2418
|
+
if (harness.runtimeOwnership === 'adapter') return null
|
|
2419
|
+
// Prove the exact per-session leaf before the first tmux signal. Missing identity is acceptable only for a
|
|
2420
|
+
// record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
|
|
2421
|
+
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2422
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
2423
|
+
if (rec.stopped || rec.status === 'queued') return null
|
|
2424
|
+
throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
|
|
2425
|
+
}
|
|
2426
|
+
const startToken = processStartToken(pid)
|
|
2427
|
+
if (!startToken) {
|
|
2428
|
+
if (rec.stopped) return null
|
|
2429
|
+
throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is not alive or has no start identity`)
|
|
2430
|
+
}
|
|
2431
|
+
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2432
|
+
const ownerNeedle = harness.leafOwnerNeedle?.(rec)
|
|
2433
|
+
if (!ownerNeedle)
|
|
2434
|
+
throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
|
|
2435
|
+
if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
|
|
2436
|
+
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
|
|
2437
|
+
return { pid, startToken, ownerNeedle }
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false): Promise<void> {
|
|
2441
|
+
// The caller resolves one readable owner before entering this seam. An absent/corrupt record never reaches
|
|
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)
|
|
2444
|
+
await assertOwned()
|
|
2445
|
+
if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session owner`)
|
|
2446
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2447
|
+
const leaf = await assertSessionLeafOwned(id, rec)
|
|
2448
|
+
// Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
|
|
2449
|
+
// wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the PID/argv proof, never the
|
|
2450
|
+
// exact tmux teardown.
|
|
1703
2451
|
await tmuxOk(['kill-session', '-t', id])
|
|
2452
|
+
if (leaf) await killAgentProcess(id, assertOwned, leaf)
|
|
1704
2453
|
launchedAt.delete(id)
|
|
1705
|
-
|
|
2454
|
+
await harness.cleanupRuntime(rec)
|
|
2455
|
+
if (requireCold) {
|
|
2456
|
+
const cold = await harness.coldRuntime?.(rec)
|
|
2457
|
+
if (cold && !cold.ok) throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`)
|
|
2458
|
+
}
|
|
1706
2459
|
}
|
|
1707
2460
|
|
|
1708
2461
|
// @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
|
|
1709
|
-
// worktree + branch + transcript intact. The
|
|
1710
|
-
// whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation
|
|
2462
|
+
// worktree + branch + transcript intact. The retained record's explicit-stop marker makes liveness `offline`
|
|
2463
|
+
// whatever its lifecycle or adapter probe, so the relaunch panel offers to --resume the SAME conversation. This is
|
|
1711
2464
|
// "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
|
|
1712
2465
|
// the freed capacity drains a queued session next (drainQueue).
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
await
|
|
1716
|
-
|
|
2466
|
+
async function stopSessionUnlocked(id: string): Promise<boolean> {
|
|
2467
|
+
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2468
|
+
try { wt = await findWorktree(id) }
|
|
2469
|
+
catch (e) {
|
|
2470
|
+
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
2471
|
+
await stopAgentProcess(id, null)
|
|
2472
|
+
throw e
|
|
2473
|
+
}
|
|
2474
|
+
if (!wt) return false
|
|
2475
|
+
await stopAgentProcess(id, wt.rec)
|
|
2476
|
+
const rec = readRecord(id)
|
|
2477
|
+
if (rec) writeRecord({ ...rec, stopped: true })
|
|
2478
|
+
requestQueueDrain() // a stop frees a slot — start the next queued session if any
|
|
1717
2479
|
return !!wt
|
|
1718
2480
|
}
|
|
2481
|
+
export const stopSession = (id: string, opts: { authorization?: Authorization } = {}): Promise<boolean> =>
|
|
2482
|
+
runSessionOperation({ op: 'stop', sessionId: id, ...(opts.authorization ? { authorization: opts.authorization } : {}) },
|
|
2483
|
+
() => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id))))
|
|
2484
|
+
|
|
2485
|
+
// @@@ archiveSession - cold storage ([[archive]]): prove and stop the exact session-owned runtime first, then
|
|
2486
|
+
// write archived:true + stopped:true. The shared adapter root is never torn down. A guard/ownership failure
|
|
2487
|
+
// leaves the record unarchived and visible; no second cleanup primitive or fail-open record write exists.
|
|
2488
|
+
async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
2489
|
+
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2490
|
+
try { wt = await findWorktree(id) }
|
|
2491
|
+
catch (e) {
|
|
2492
|
+
if (e instanceof SessionRecordUnusable) throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`)
|
|
2493
|
+
throw e
|
|
2494
|
+
}
|
|
2495
|
+
if (!wt) return false
|
|
2496
|
+
if (!on) throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime')
|
|
2497
|
+
if (wt.rec.status === 'queued') throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`)
|
|
2498
|
+
const retired = retirementReason(wt.rec)
|
|
2499
|
+
if (retired) throw new ResourceConflict(`refusing to archive ${id}: ${retired}`)
|
|
2500
|
+
archiving.add(id)
|
|
2501
|
+
try {
|
|
2502
|
+
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
2503
|
+
// A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
|
|
2504
|
+
// adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
|
|
2505
|
+
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
2506
|
+
const proofSnap = await liveSnapshot()
|
|
2507
|
+
if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
|
|
2508
|
+
const proofLv = h.runtimeOwnership === 'adapter'
|
|
2509
|
+
? (proofSnap.windows.has(id) ? 'online' : 'offline')
|
|
2510
|
+
: liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap)
|
|
2511
|
+
if (proofLv === 'unknown' || proofLv === 'starting') throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`)
|
|
2512
|
+
if (proofLv === 'offline') {
|
|
2513
|
+
// A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
|
|
2514
|
+
// an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
|
|
2515
|
+
// the thread's archived/non-archived disk collection before we can claim it is already cold.
|
|
2516
|
+
const rootAbsent = await Promise.all((h.sharedRuntimes?.(runtimeRoot()) ?? []).map(async (descriptor) => {
|
|
2517
|
+
if (!descriptor.residency) return false
|
|
2518
|
+
const state = await descriptor.residency()
|
|
2519
|
+
return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
|
|
2520
|
+
})).then((states) => states.some(Boolean))
|
|
2521
|
+
if (rootAbsent) return true
|
|
2522
|
+
const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
|
|
2523
|
+
if (!pre || pre.ok) {
|
|
2524
|
+
const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true })
|
|
2525
|
+
if (!cold || cold.ok) return true
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
// Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
|
|
2530
|
+
// row instead of relying on a hidden bit while a runtime proof is missing.
|
|
2531
|
+
if (wt.rec.archived) {
|
|
2532
|
+
writeRecord({ ...wt.rec, archived: false, coldProof: null })
|
|
2533
|
+
wt = await findWorktree(id)
|
|
2534
|
+
if (!wt) return false
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
const snap = await liveSnapshot()
|
|
2538
|
+
if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
|
|
2539
|
+
const lv = h.runtimeOwnership === 'adapter'
|
|
2540
|
+
? 'offline'
|
|
2541
|
+
: liveness({ ...wt.rec, archived: false, stopped: false }, snap)
|
|
2542
|
+
if (lv === 'unknown' || lv === 'starting')
|
|
2543
|
+
throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
|
|
2544
|
+
// The adapter guard runs BEFORE any tmux/process signal. Active/unknown Codex turns and owned descendants
|
|
2545
|
+
// refuse here, leaving the leaf/shared root/record untouched; coldRuntime is the commit cleanup after this.
|
|
2546
|
+
const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
|
|
2547
|
+
if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
|
|
2548
|
+
// Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
|
|
2549
|
+
// teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
|
|
2550
|
+
// cleanup primitive is invented.
|
|
2551
|
+
let coldCommitted = false
|
|
2552
|
+
let coldAttempted = false
|
|
2553
|
+
try {
|
|
2554
|
+
coldAttempted = true
|
|
2555
|
+
await stopAgentProcess(id, { ...wt.rec, archived: false, stopped: lv === 'offline' }, true)
|
|
2556
|
+
coldCommitted = true
|
|
2557
|
+
const latest = readRecord(id)
|
|
2558
|
+
if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
|
|
2559
|
+
const finalSnap = await liveSnapshot()
|
|
2560
|
+
if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
|
|
2561
|
+
const finalLv = h.runtimeOwnership === 'adapter'
|
|
2562
|
+
? (finalSnap.windows.has(id) ? 'online' : 'offline')
|
|
2563
|
+
: liveness({ ...latest, archived: false, stopped: false }, finalSnap)
|
|
2564
|
+
if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
|
|
2565
|
+
throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
|
|
2566
|
+
writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
if (coldCommitted) {
|
|
2569
|
+
const restored = await h.restoreRuntime?.(wt.rec)
|
|
2570
|
+
if (restored && !restored.ok) {
|
|
2571
|
+
const current = readRecord(id)
|
|
2572
|
+
if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: null, adapterRecovery: `restore-runtime:${restored.reason}` })
|
|
2573
|
+
console.error(`spex: archive compensation for ${id} failed: ${restored.reason}`)
|
|
2574
|
+
}
|
|
2575
|
+
} else if (coldAttempted && error instanceof Error && /compensation failed|state is unknown|reconciliation failed/i.test(error.message)) {
|
|
2576
|
+
const current = readRecord(id)
|
|
2577
|
+
if (current) writeRecord({ ...current, archived: false, stopped: true, coldProof: coldProofFor(current), adapterRecovery: `restore-runtime:${error.message}` })
|
|
2578
|
+
}
|
|
2579
|
+
throw error
|
|
2580
|
+
}
|
|
2581
|
+
requestQueueDrain()
|
|
2582
|
+
return true
|
|
2583
|
+
} finally { archiving.delete(id) }
|
|
2584
|
+
}
|
|
2585
|
+
export const archiveSession = (id: string, on = true): Promise<boolean> => {
|
|
2586
|
+
if (!on) return archiveSessionUnarchive(id)
|
|
2587
|
+
return runSessionOperation({ op: 'archive', sessionId: id },
|
|
2588
|
+
() => withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on))))
|
|
2589
|
+
}
|
|
2590
|
+
async function archiveSessionUnarchive(id: string): Promise<boolean> {
|
|
2591
|
+
const wt = await findWorktree(id)
|
|
2592
|
+
if (!wt) return false
|
|
2593
|
+
if (!wt.rec.archived) return true
|
|
2594
|
+
const resumed = await resumeSession(id)
|
|
2595
|
+
if (!resumed.ok) throw new ResourceConflict(resumed.error || `refusing to resume ${id}`)
|
|
2596
|
+
return true
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
|
|
2600
|
+
// re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
|
|
2601
|
+
// that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
|
|
2602
|
+
// This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
|
|
2603
|
+
async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void> {
|
|
2604
|
+
if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
|
|
2605
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`)
|
|
2606
|
+
if (rec.adapterRecovery)
|
|
2607
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
|
|
2608
|
+
|
|
2609
|
+
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
2610
|
+
if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2611
|
+
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
|
|
2612
|
+
if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
|
|
2613
|
+
if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
|
|
2614
|
+
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2615
|
+
if (Number.isFinite(pid) && pid > 0 && processStartToken(pid))
|
|
2616
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${pid} is live or recycled; ownership is ambiguous`)
|
|
2617
|
+
|
|
2618
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2619
|
+
if (harness.coldRetirementPreflight) {
|
|
2620
|
+
const proof = await harness.coldRetirementPreflight(rec)
|
|
2621
|
+
if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
|
|
2622
|
+
return
|
|
2623
|
+
}
|
|
2624
|
+
const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
2625
|
+
let everySharedRootAbsent = descriptors.length > 0
|
|
2626
|
+
for (const descriptor of descriptors) {
|
|
2627
|
+
const resident: { healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean } = descriptor.residency
|
|
2628
|
+
? await descriptor.residency()
|
|
2629
|
+
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
|
|
2630
|
+
if (!resident.healthy)
|
|
2631
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`)
|
|
2632
|
+
if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
|
|
2633
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`)
|
|
2634
|
+
everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true
|
|
2635
|
+
}
|
|
2636
|
+
if (harness.coldPreflight && !everySharedRootAbsent) {
|
|
2637
|
+
const proof = await harness.coldPreflight(rec)
|
|
2638
|
+
if (!proof.ok) throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`)
|
|
2639
|
+
if (!proof.alreadyCold)
|
|
2640
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`)
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
|
|
2645
|
+
// this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
|
|
2646
|
+
// No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
|
|
2647
|
+
async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string, branch: string | null): Promise<void> {
|
|
2648
|
+
if (rec.status !== 'queued' || rec.harnessSessionId)
|
|
2649
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`)
|
|
2650
|
+
if (rec.adapterRecovery || launching.has(id))
|
|
2651
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
|
|
2652
|
+
|
|
2653
|
+
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
2654
|
+
if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2655
|
+
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
|
|
2656
|
+
if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
|
|
2657
|
+
if (socket === 'unproven') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous state is ambiguous`)
|
|
2658
|
+
const pidPath = sessionArtifactPath(id, 'agent.pid')
|
|
2659
|
+
if (existsSync(pidPath)) {
|
|
2660
|
+
const pid = readAgentPid(pidPath)
|
|
2661
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`)
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
if (existsSync(path)) {
|
|
2665
|
+
const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all'])
|
|
2666
|
+
if (!status.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree status is unreadable`)
|
|
2667
|
+
if (status.stdout.trim()) throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree has dirty work`)
|
|
2668
|
+
}
|
|
2669
|
+
if (branch) {
|
|
2670
|
+
const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`])
|
|
2671
|
+
if (resolved.ok) {
|
|
2672
|
+
const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`])
|
|
2673
|
+
if (!count.ok) throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch ancestry is unreadable`)
|
|
2674
|
+
const ahead = Number(count.stdout.trim())
|
|
2675
|
+
if (!Number.isFinite(ahead) || ahead !== 0)
|
|
2676
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`)
|
|
2677
|
+
} else if (resolved.failure !== 'exit') {
|
|
2678
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch identity is unreadable`)
|
|
2679
|
+
} else if (existsSync(path)) {
|
|
2680
|
+
throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree exists but branch ${branch} is missing`)
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
1719
2684
|
|
|
1720
|
-
// @@@ closeSession - the REMOVAL (human-confirmed): stop's
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
// then the store sweep (stop KEEPS the record so the session stays on the board offline; close discards it).
|
|
2685
|
+
// @@@ closeSession - the REMOVAL (human-confirmed): a live row uses stop's exact kill, while a proven-cold
|
|
2686
|
+
// archive or never-launched queue uses its target-only read proof above. All then remove the worktree + branch and the session's whole
|
|
2687
|
+
// global-store record dir — the work is gone, not just stopped. The git/store teardown remains one path.
|
|
1724
2688
|
// The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
|
|
1725
2689
|
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
2690
|
+
// A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
|
|
2691
|
+
// control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
|
|
2692
|
+
async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
2693
|
+
let wt: { path: string; branch: string | null; rec: SessRec } | null = null
|
|
2694
|
+
try { wt = await findWorktree(id) }
|
|
2695
|
+
catch (e) {
|
|
2696
|
+
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
2697
|
+
const quarantined = quarantineRecord(id)
|
|
2698
|
+
const runtime = sessionStoreDir(id)
|
|
2699
|
+
const evidence = quarantined
|
|
2700
|
+
? `Original bytes were copied to ${quarantined}`
|
|
2701
|
+
: `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
|
|
2702
|
+
let guard = 'no readable session record proves the adapter or leaf owner'
|
|
2703
|
+
try { await stopAgentProcess(id, null) }
|
|
2704
|
+
catch (error) { guard = error instanceof Error ? error.message : String(error) }
|
|
2705
|
+
throw new SessionRecordUnusable('corrupt', id,
|
|
2706
|
+
`refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`)
|
|
2707
|
+
}
|
|
2708
|
+
if (!wt) return false
|
|
2709
|
+
if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
|
|
2710
|
+
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
2711
|
+
else await stopAgentProcess(id, wt.rec)
|
|
2712
|
+
let slot: string | null = null
|
|
2713
|
+
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
2714
|
+
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
2715
|
+
if (existsSync(wt.path)) {
|
|
2716
|
+
const removed = await gitTry(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
|
|
2717
|
+
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`)
|
|
2718
|
+
if (existsSync(wt.path)) throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`)
|
|
2719
|
+
}
|
|
2720
|
+
if (wt.branch) {
|
|
2721
|
+
const branchRef = `refs/heads/${wt.branch}`
|
|
2722
|
+
const present = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2723
|
+
if (present.ok) {
|
|
2724
|
+
const removed = await gitTry(['-C', mainRoot(), 'branch', '-D', wt.branch])
|
|
2725
|
+
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`)
|
|
2726
|
+
const remaining = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2727
|
+
if (remaining.ok || remaining.failure !== 'exit') throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`)
|
|
2728
|
+
} else if (present.failure !== 'exit') {
|
|
2729
|
+
throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`)
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
|
|
2733
|
+
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
|
|
2734
|
+
catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
2735
|
+
if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
|
|
2736
|
+
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
2737
|
+
return true
|
|
2738
|
+
}
|
|
2739
|
+
export const closeSession = (id: string): Promise<boolean> =>
|
|
2740
|
+
runSessionOperation({ op: 'close', sessionId: id },
|
|
2741
|
+
() => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
|
|
2742
|
+
|
|
2743
|
+
// @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
|
|
2744
|
+
// evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
|
|
2745
|
+
// session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
|
|
2746
|
+
// and reproducible); the shelf is never read by the product, it is there for the human who asks "what broke?".
|
|
2747
|
+
function quarantineRecord(id: string): string | null {
|
|
2748
|
+
let entry
|
|
2749
|
+
try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
|
|
2750
|
+
if (entry.kind !== 'corrupt') return null
|
|
2751
|
+
try {
|
|
2752
|
+
const shelf = join(runtimeRoot(), 'corrupt')
|
|
2753
|
+
mkdirSync(shelf, { recursive: true })
|
|
2754
|
+
const dest = join(shelf, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
|
|
2755
|
+
writeFileSync(dest, readFileSync(entry.path))
|
|
2756
|
+
console.error(`spex: session ${id.slice(0, 8)} had an unreadable record; its original bytes are preserved at ${dest}`)
|
|
2757
|
+
return dest
|
|
2758
|
+
} catch (e) {
|
|
2759
|
+
console.error(`spex: could not quarantine the unreadable record for ${id}: ${e instanceof Error ? e.message : e}`)
|
|
2760
|
+
return null
|
|
2761
|
+
}
|
|
1739
2762
|
}
|
|
1740
2763
|
|
|
1741
2764
|
// @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
|
|
@@ -1749,7 +2772,7 @@ export async function closeSession(id: string): Promise<boolean> {
|
|
|
1749
2772
|
export type CaptureResult = { ok: true; pane: string } | { ok: false; reason: 'unknown' | 'offline' | 'capture-failed' }
|
|
1750
2773
|
export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
1751
2774
|
if (!(await alive(id))) {
|
|
1752
|
-
const known = (await listSessions()).some((s) => s.id === id)
|
|
2775
|
+
const known = (await listSessions(true)).some((s) => s.id === id)
|
|
1753
2776
|
return { ok: false, reason: known ? 'offline' : 'unknown' }
|
|
1754
2777
|
}
|
|
1755
2778
|
try { return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) } }
|
|
@@ -1768,9 +2791,11 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
|
1768
2791
|
export const STATUS_GLYPH: Record<DisplayStatus, string> = {
|
|
1769
2792
|
working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
|
|
1770
2793
|
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
|
|
2794
|
+
corrupt: '\u26a0', retired: '\u2691',
|
|
1771
2795
|
}
|
|
1772
2796
|
const ANSI: Record<DisplayStatus, string> = {
|
|
1773
2797
|
working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
|
|
2798
|
+
corrupt: '31', retired: '90',
|
|
1774
2799
|
}
|
|
1775
2800
|
|
|
1776
2801
|
// @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
|
|
@@ -1913,11 +2938,13 @@ export function launchEvent(s: Session): string {
|
|
|
1913
2938
|
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
1914
2939
|
return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
1915
2940
|
}
|
|
1916
|
-
// @@@ source -
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
|
|
2941
|
+
// @@@ source/presenceSource - event population and existence truth are deliberately separate projections. The
|
|
2942
|
+
// broad CLI watch passes the default active-only source so it never enumerates the archive shelf, plus an
|
|
2943
|
+
// all-record `presenceSource` so a hidden archive is not mistaken for a removed worktree. An explicit selector
|
|
2944
|
+
// passes the all-record source for BOTH, allowing that one target's offline archive/resume transitions through.
|
|
2945
|
+
// Both are backend clients (client.ts), so `spex session watch` monitors the machine named by
|
|
2946
|
+
// SPEXCODE_API_URL; a forgotten source must be a compile error, never a silent local-board fallback.
|
|
2947
|
+
export type WatchOpts = { source: () => Promise<Session[]>; presenceSource?: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
|
|
1921
2948
|
// @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
|
|
1922
2949
|
// plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
|
|
1923
2950
|
// "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
|
|
@@ -1937,7 +2964,7 @@ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]
|
|
|
1937
2964
|
// supervisor must never be able to read a transport failure as a session state (issue #40).
|
|
1938
2965
|
export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
1939
2966
|
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
1940
|
-
const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
2967
|
+
const { source, presenceSource = source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
1941
2968
|
const tag = as ? `[${as}] ` : ''
|
|
1942
2969
|
const prev = new Map<string, DisplayStatus>()
|
|
1943
2970
|
const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
|
|
@@ -1950,14 +2977,19 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1950
2977
|
let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
|
|
1951
2978
|
for (;;) {
|
|
1952
2979
|
try {
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
// emit, never whether a session is present
|
|
1957
|
-
|
|
1958
|
-
|
|
2980
|
+
// EVENTS come from the caller's chosen population (broad watch uses active-only; an explicit selector
|
|
2981
|
+
// uses history). EXISTENCE always comes from the all-record presence source, so a cold archive can never
|
|
2982
|
+
// look removed merely because the default event population hides it. The `statuses` filter governs only
|
|
2983
|
+
// which transitions we emit, never whether a session is present.
|
|
2984
|
+
const eventRows = await source()
|
|
2985
|
+
// Explicit selector wait passes the same history function for both roles. Reuse that one snapshot so a
|
|
2986
|
+
// single poll cannot combine two HTTP instants (or pay for the same request twice); broad watch supplies
|
|
2987
|
+
// distinct function references because it genuinely needs active events plus all-record presence.
|
|
2988
|
+
const presenceRows = presenceSource === source ? eventRows : await presenceSource()
|
|
2989
|
+
const all = selectSessions(eventRows, selectors)
|
|
2990
|
+
const presence = selectSessions(presenceRows, selectors)
|
|
1959
2991
|
warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
|
|
1960
|
-
const ids = new Set(
|
|
2992
|
+
const ids = new Set(presence.map((s) => s.id))
|
|
1961
2993
|
const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
1962
2994
|
let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
|
|
1963
2995
|
for (const s of all) {
|
|
@@ -1993,7 +3025,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1993
3025
|
// before we hand control back.
|
|
1994
3026
|
if (until) {
|
|
1995
3027
|
if (edge) return { reached: edge.status, path: edge.path }
|
|
1996
|
-
if (!
|
|
3028
|
+
if (!presence.length) return { gone: true, path: anyPath() }
|
|
1997
3029
|
}
|
|
1998
3030
|
} catch (e) {
|
|
1999
3031
|
// a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
|
|
@@ -2027,7 +3059,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
2027
3059
|
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
2028
3060
|
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
2029
3061
|
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
2030
|
-
|
|
3062
|
+
async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
2031
3063
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
2032
3064
|
const rec = readRecord(id)
|
|
2033
3065
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
@@ -2051,15 +3083,22 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
2051
3083
|
if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
|
|
2052
3084
|
return r
|
|
2053
3085
|
}
|
|
3086
|
+
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
3087
|
+
// The record lock spans the delivery RPC. Archive preflight and leaf teardown cannot race a product turn
|
|
3088
|
+
// start/steer/input from another CLI process and then discover it only after killing the pane.
|
|
3089
|
+
return runSessionOperation({ op: 'send', sessionId: id }, () => withRecordLock(id, () => sendTextUnlocked(id, text, from, opts)))
|
|
3090
|
+
}
|
|
2054
3091
|
|
|
2055
3092
|
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
2056
3093
|
// confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
|
|
2057
3094
|
export async function interruptSession(id: string): Promise<DispatchResult> {
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
3095
|
+
return runSessionOperation({ op: 'interrupt', sessionId: id }, () => withRecordLock(id, async () => {
|
|
3096
|
+
const rec = readRecord(id)
|
|
3097
|
+
if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
|
|
3098
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3099
|
+
if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
|
|
3100
|
+
return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
|
|
3101
|
+
}))
|
|
2063
3102
|
}
|
|
2064
3103
|
|
|
2065
3104
|
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
@@ -2115,13 +3154,15 @@ function rawKeyArgs(id: string, key: string): string[] | null {
|
|
|
2115
3154
|
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
2116
3155
|
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
2117
3156
|
export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
3157
|
+
return runSessionOperation({ op: 'raw-key-input', sessionId: id }, () => withRecordLock(id, async () => {
|
|
3158
|
+
const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
|
|
3159
|
+
if (list.length === 0 || !(await alive(id))) return false
|
|
3160
|
+
let sent = false
|
|
3161
|
+
for (const k of list) {
|
|
3162
|
+
const args = rawKeyArgs(id, k)
|
|
3163
|
+
if (!args) continue
|
|
3164
|
+
await tmux(args); sent = true
|
|
3165
|
+
}
|
|
3166
|
+
return sent
|
|
3167
|
+
}))
|
|
2127
3168
|
}
|