spexcode 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -6,21 +6,24 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mk
|
|
|
6
6
|
import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
9
|
-
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
|
|
9
|
+
import { git, gitA, gitTry, isGitObjectId, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
|
|
10
10
|
import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
|
|
11
11
|
import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
12
12
|
import { materialize } from './materialize.js'
|
|
13
13
|
import { mainBranch, mainRoot, 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'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { readSessionFiles } from './session-files.js'
|
|
15
|
+
import { readSessionWebs, type SessionWeb } from './session-web.js'
|
|
16
|
+
import { appendSent, recordStatus, lastHumanSendVia, sentDispatchReceipt, settleSentDispatch, type SentDispatchReceipt, type SentDispatchState } from './session-timeline.js'
|
|
17
|
+
import { drain, enqueue, ensurePendingWhileLocked, owesDelivery, pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, senderDeliveryRevoked, withDeliveryLocks, type PendingMessage } from './delivery-queue.js'
|
|
16
18
|
import { stripRefSigil } from './mentions.js'
|
|
17
19
|
import { shQuote } from './sh.js'
|
|
18
|
-
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
20
|
+
import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
19
21
|
import { processStartToken } from './process-identity.js'
|
|
20
22
|
import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
|
|
21
23
|
|
|
22
24
|
const pexec = promisify(execFile)
|
|
23
25
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
26
|
+
const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
|
|
24
27
|
const HARNESS = defaultHarness
|
|
25
28
|
const COLS = 120, ROWS = 32
|
|
26
29
|
const DEFAULT_MAX_ACTIVE = 8
|
|
@@ -72,7 +75,7 @@ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', noth
|
|
|
72
75
|
|
|
73
76
|
export type Session = {
|
|
74
77
|
id: string; node: string | null; branch: string | null; path: string
|
|
75
|
-
label: string;
|
|
78
|
+
label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
|
|
76
79
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
77
80
|
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
|
|
78
81
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
@@ -83,6 +86,40 @@ export type Session = {
|
|
|
83
86
|
archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
|
|
84
87
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
85
88
|
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
89
|
+
files?: string[] // live posted paths ([[files]]), read from the session store with the rest of the projection
|
|
90
|
+
web?: SessionWeb[] // live posted loopback services ([[web]]), read from the session store with the rest of the projection
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// HTTP carries no authenticated session identity. A CLI may report its environment id, but that remains
|
|
94
|
+
// evidence supplied by the caller rather than authority over the target or a fact about who performed close.
|
|
95
|
+
export type CloseSource = { kind: 'unverified-session-claim'; id: string } | { kind: 'user' }
|
|
96
|
+
|
|
97
|
+
function normalizeCloseSource(raw: unknown): CloseSource {
|
|
98
|
+
if (raw == null) return { kind: 'user' }
|
|
99
|
+
if (!raw || typeof raw !== 'object') throw new ResourceConflict('refusing session close: source must be user or an unverified session claim')
|
|
100
|
+
const source = raw as { kind?: unknown; id?: unknown }
|
|
101
|
+
if (source.kind === 'user') return { kind: 'user' }
|
|
102
|
+
if (source.kind === 'unverified-session-claim' && typeof source.id === 'string' && source.id.trim())
|
|
103
|
+
return { kind: 'unverified-session-claim', id: source.id.trim() }
|
|
104
|
+
throw new ResourceConflict('refusing session close: source must be user or an unverified session claim')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function appendCloseLedger(id: string, rec: SessRec, source: CloseSource): void {
|
|
108
|
+
const path = join(runtimeRoot(), 'session-close-ledger.ndjson')
|
|
109
|
+
const event = {
|
|
110
|
+
version: 1,
|
|
111
|
+
action: 'close-authorized',
|
|
112
|
+
at: new Date().toISOString(),
|
|
113
|
+
source,
|
|
114
|
+
target: {
|
|
115
|
+
id,
|
|
116
|
+
harness: rec.harness,
|
|
117
|
+
thread: rec.harnessSessionId,
|
|
118
|
+
worktree: rec.worktreePath,
|
|
119
|
+
branch: rec.branch,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`)
|
|
86
123
|
}
|
|
87
124
|
|
|
88
125
|
function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
|
|
@@ -111,18 +148,26 @@ function removeLaunchFile(id: string): void {
|
|
|
111
148
|
|
|
112
149
|
// One line, bounded — the launch prompt's shape when it enters a compact headline.
|
|
113
150
|
export const HEADLINE_PREVIEW_COLUMNS = 60
|
|
151
|
+
function isBareUrl(text: string): boolean {
|
|
152
|
+
return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
|
|
153
|
+
}
|
|
114
154
|
function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
|
|
115
|
-
const
|
|
155
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
156
|
+
const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
|
|
116
157
|
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
117
158
|
}
|
|
118
159
|
|
|
119
160
|
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
120
161
|
r.name || r.node || r.title || r.branch || r.id
|
|
121
|
-
export const
|
|
122
|
-
r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
|
|
162
|
+
export const deriveTitle = (r: { name?: string | null; activity?: string | null; note?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
163
|
+
r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id
|
|
164
|
+
// Compatibility for package consumers that still import the old name.
|
|
165
|
+
export const deriveHeadline = deriveTitle
|
|
123
166
|
|
|
124
167
|
export const sessionLabel = (s: Session): string => s.label
|
|
125
|
-
export const
|
|
168
|
+
export const sessionTitle = (s: Session): string => s.title
|
|
169
|
+
// Compatibility for older callers; all visible surfaces now resolve through `title`.
|
|
170
|
+
export const sessionHeadline = sessionTitle
|
|
126
171
|
|
|
127
172
|
// @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
|
|
128
173
|
// HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
|
|
@@ -130,6 +175,9 @@ export const sessionHeadline = (s: Session): string => s.headline
|
|
|
130
175
|
// execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
|
|
131
176
|
// "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
|
|
132
177
|
const TMUX_PROBE_TIMEOUT_MS = 4000
|
|
178
|
+
// A destructive close already names one target, so it can afford the longer bounded probe without making
|
|
179
|
+
// every dashboard refresh wait behind an overloaded tmux server.
|
|
180
|
+
const TARGET_PROBE_TIMEOUT_MS = 15000
|
|
133
181
|
async function tmux(args: string[], timeoutMs?: number): Promise<string> {
|
|
134
182
|
const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
|
|
135
183
|
return stdout
|
|
@@ -163,6 +211,7 @@ export type SessRec = {
|
|
|
163
211
|
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
164
212
|
createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
|
|
165
213
|
createPayloadHash?: string | null // exact normalized create payload bound to createRequestId
|
|
214
|
+
base?: string | null // explicit fork point the creator pinned; absent/null = the auto-detected source-of-truth branch
|
|
166
215
|
launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
|
|
167
216
|
}
|
|
168
217
|
type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
|
|
@@ -290,10 +339,11 @@ async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: Ab
|
|
|
290
339
|
const release = await acquireRecordLock(id, 30_000, signal)
|
|
291
340
|
try { return await body() } finally { release() }
|
|
292
341
|
}
|
|
293
|
-
function
|
|
342
|
+
export function withSessionRecordLockSync<T>(id: string, body: () => T): T {
|
|
294
343
|
const release = acquireRecordLockSync(id)
|
|
295
344
|
try { return body() } finally { release() }
|
|
296
345
|
}
|
|
346
|
+
const withRecordLockSync = withSessionRecordLockSync
|
|
297
347
|
function tryRecordLockSync(id: string): (() => void) | null {
|
|
298
348
|
mkdirSync(recordLockRoot(), { recursive: true })
|
|
299
349
|
const path = recordLockPath(id)
|
|
@@ -357,6 +407,7 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
|
357
407
|
launchOwner: launchOwner || null,
|
|
358
408
|
createRequestId: raw.create_request_id || null,
|
|
359
409
|
createPayloadHash: raw.create_payload_hash || null,
|
|
410
|
+
base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
|
|
360
411
|
launchReadinessPending: pendingRaw ? {
|
|
361
412
|
version: 1,
|
|
362
413
|
startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
|
|
@@ -424,6 +475,9 @@ function writeRecord(rec: SessRec): void {
|
|
|
424
475
|
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
425
476
|
create_request_id: rec.createRequestId ?? '',
|
|
426
477
|
create_payload_hash: rec.createPayloadHash ?? '',
|
|
478
|
+
// Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
|
|
479
|
+
// restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
|
|
480
|
+
...(rec.base ? { base: rec.base } : {}),
|
|
427
481
|
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
428
482
|
version: 1,
|
|
429
483
|
startedAt: rec.launchReadinessPending.startedAt,
|
|
@@ -449,25 +503,216 @@ function writeRecord(rec: SessRec): void {
|
|
|
449
503
|
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
450
504
|
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
451
505
|
recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
|
|
506
|
+
scheduleWatchNotifications(rec)
|
|
452
507
|
}
|
|
453
508
|
}
|
|
454
509
|
|
|
510
|
+
type WatchEntry = { watcher: string; createdAt: string }
|
|
511
|
+
export type SessionWatch = { target: string; createdAt: string }
|
|
512
|
+
const watchPath = (target: string) => sessionArtifactPath(target, 'watchers.json')
|
|
513
|
+
|
|
514
|
+
function readWatchEntries(target: string): WatchEntry[] {
|
|
515
|
+
try {
|
|
516
|
+
const raw = JSON.parse(readFileSync(watchPath(target), 'utf8')) as unknown
|
|
517
|
+
if (!Array.isArray(raw)) return []
|
|
518
|
+
const seen = new Set<string>()
|
|
519
|
+
return raw.flatMap((entry): WatchEntry[] => {
|
|
520
|
+
if (!entry || typeof entry !== 'object') return []
|
|
521
|
+
const watcher = (entry as WatchEntry).watcher
|
|
522
|
+
const createdAt = (entry as WatchEntry).createdAt
|
|
523
|
+
if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher)) return []
|
|
524
|
+
seen.add(watcher)
|
|
525
|
+
return [{ watcher, createdAt }]
|
|
526
|
+
})
|
|
527
|
+
} catch { return [] }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function writeWatchEntries(target: string, entries: WatchEntry[]): void {
|
|
531
|
+
const path = watchPath(target)
|
|
532
|
+
if (!entries.length) { try { unlinkSync(path) } catch { /* already absent */ }; return }
|
|
533
|
+
const dir = sessionStoreDir(target)
|
|
534
|
+
mkdirSync(dir, { recursive: true })
|
|
535
|
+
const tmp = join(dir, `.watchers.json.${process.pid}.tmp`)
|
|
536
|
+
writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n')
|
|
537
|
+
renameSync(tmp, path)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function managedWatchRecord(id: string): SessRec {
|
|
541
|
+
const rec = readRecord(id)
|
|
542
|
+
if (!rec?.governed) throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`)
|
|
543
|
+
return rec
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function watchMessage(target: SessRec): string {
|
|
547
|
+
const status = target.status === 'awaiting'
|
|
548
|
+
? PROPOSAL_STATUS[target.proposal ?? 'nothing']
|
|
549
|
+
: target.status === 'active' ? 'working' : target.status
|
|
550
|
+
const note = target.note ? ` — ${target.note}` : ''
|
|
551
|
+
return `[spex watch] ${target.session} is ${status}${note}`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function scheduleWatchNotifications(target: SessRec): void {
|
|
555
|
+
const watchers = readWatchEntries(target.session).map((entry) => entry.watcher)
|
|
556
|
+
if (!watchers.length) return
|
|
557
|
+
queueMicrotask(() => {
|
|
558
|
+
for (const watcher of watchers) {
|
|
559
|
+
void sendText(watcher, watchMessage(target), target.session).then((result) => {
|
|
560
|
+
if (!result.ok) console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`)
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export async function subscribeSessionWatch(watcher: string, targets: string[]): Promise<{ watched: string[] }> {
|
|
567
|
+
managedWatchRecord(watcher)
|
|
568
|
+
const watched: string[] = []
|
|
569
|
+
for (const target of [...new Set(targets)]) {
|
|
570
|
+
if (target === watcher) throw new ResourceConflict('a session cannot watch itself')
|
|
571
|
+
const targetRecord = managedWatchRecord(target)
|
|
572
|
+
withRecordLockSync(target, () => {
|
|
573
|
+
const entries = readWatchEntries(target)
|
|
574
|
+
if (!entries.some((entry) => entry.watcher === watcher)) {
|
|
575
|
+
writeWatchEntries(target, [...entries, { watcher, createdAt: new Date().toISOString() }])
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
const delivered = await sendText(watcher, watchMessage(targetRecord), target)
|
|
579
|
+
if (!delivered.ok) throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`)
|
|
580
|
+
watched.push(target)
|
|
581
|
+
}
|
|
582
|
+
return { watched }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function listSessionWatches(watcher: string): SessionWatch[] {
|
|
586
|
+
managedWatchRecord(watcher)
|
|
587
|
+
const watches: SessionWatch[] = []
|
|
588
|
+
for (const target of listSessionIds()) {
|
|
589
|
+
const entries = readWatchEntries(target)
|
|
590
|
+
const active = entries.filter((entry) => {
|
|
591
|
+
try { return !!readRecord(entry.watcher)?.governed } catch { return false }
|
|
592
|
+
})
|
|
593
|
+
if (active.length !== entries.length) writeWatchEntries(target, active)
|
|
594
|
+
for (const entry of active) if (entry.watcher === watcher) watches.push({ target, createdAt: entry.createdAt })
|
|
595
|
+
}
|
|
596
|
+
return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function cancelSessionWatch(watcher: string, targets: string[]): number {
|
|
600
|
+
managedWatchRecord(watcher)
|
|
601
|
+
let cancelled = 0
|
|
602
|
+
for (const target of [...new Set(targets)]) {
|
|
603
|
+
withRecordLockSync(target, () => {
|
|
604
|
+
const entries = readWatchEntries(target)
|
|
605
|
+
const kept = entries.filter((entry) => entry.watcher !== watcher)
|
|
606
|
+
if (kept.length !== entries.length) {
|
|
607
|
+
writeWatchEntries(target, kept)
|
|
608
|
+
cancelled++
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
return cancelled
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export type SessionReparentResult = { children: string[]; parent: string; notified: string[] }
|
|
616
|
+
|
|
617
|
+
async function withRecordLocks<T>(ids: string[], body: () => Promise<T>, index = 0): Promise<T> {
|
|
618
|
+
if (index >= ids.length) return body()
|
|
619
|
+
return withRecordLock(ids[index], () => withRecordLocks(ids, body, index + 1))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function assertReparentable(children: string[], parent: string, records: Map<string, SessRec>): void {
|
|
623
|
+
if (!children.length) throw new ResourceConflict('reparent needs at least one child session')
|
|
624
|
+
managedWatchRecord(parent)
|
|
625
|
+
for (const id of children) {
|
|
626
|
+
const child = records.get(id)
|
|
627
|
+
if (!child?.governed) throw new ResourceConflict(`session ${id} is not a governed child session`)
|
|
628
|
+
if (id === parent) throw new ResourceConflict('a session cannot be its own parent')
|
|
629
|
+
}
|
|
630
|
+
const childIds = new Set(children)
|
|
631
|
+
const seen = new Set<string>()
|
|
632
|
+
for (let current: string | null = parent; current; ) {
|
|
633
|
+
if (childIds.has(current)) throw new ResourceConflict(`reparent would create a parent cycle through ${current}`)
|
|
634
|
+
if (seen.has(current)) throw new ResourceConflict(`cannot reparent through malformed parent cycle at ${current}`)
|
|
635
|
+
seen.add(current)
|
|
636
|
+
current = readRecord(current)?.parent ?? null
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function reparentSessionRecords(rawChildren: string[], parent: string): Promise<SessionReparentResult> {
|
|
641
|
+
const children = [...new Set(rawChildren)].sort()
|
|
642
|
+
if (!parent) throw new ResourceConflict('reparent needs a destination parent session')
|
|
643
|
+
const notify: SessRec[] = []
|
|
644
|
+
await withRecordLock('session-reparent-transaction', async () => {
|
|
645
|
+
// Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
|
|
646
|
+
// this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
|
|
647
|
+
const before = new Map(children.map((id) => [id, managedWatchRecord(id)]))
|
|
648
|
+
assertReparentable(children, parent, before)
|
|
649
|
+
const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))]
|
|
650
|
+
await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
|
|
651
|
+
const current = new Map(children.map((id) => [id, managedWatchRecord(id)]))
|
|
652
|
+
assertReparentable(children, parent, current)
|
|
653
|
+
const snapshots = children.map((id) => ({ id, record: current.get(id)!, watchers: readWatchEntries(id), pending: pendingSnapshot(id) }))
|
|
654
|
+
try {
|
|
655
|
+
for (const snapshot of snapshots) {
|
|
656
|
+
const { record, watchers } = snapshot
|
|
657
|
+
const hadNewParent = watchers.some((entry) => entry.watcher === parent)
|
|
658
|
+
const retainedNewParent = watchers.find((entry) => entry.watcher === parent)
|
|
659
|
+
const nextWatchers = watchers.filter((entry) => entry.watcher !== record.parent && entry.watcher !== parent)
|
|
660
|
+
nextWatchers.push(retainedNewParent ?? { watcher: parent, createdAt: new Date().toISOString() })
|
|
661
|
+
writeWatchEntries(snapshot.id, nextWatchers)
|
|
662
|
+
if (record.parent !== parent) writeRecord({ ...record, parent })
|
|
663
|
+
if (record.parent !== parent || !hadNewParent) notify.push({ ...record, parent })
|
|
664
|
+
}
|
|
665
|
+
for (const snapshot of snapshots) {
|
|
666
|
+
if (snapshot.record.parent && snapshot.record.parent !== parent)
|
|
667
|
+
revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent)
|
|
668
|
+
}
|
|
669
|
+
} catch (error) {
|
|
670
|
+
let rollbackFailure: unknown = null
|
|
671
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
672
|
+
try {
|
|
673
|
+
replacePendingWhileLocked(snapshot.id, snapshot.pending)
|
|
674
|
+
writeWatchEntries(snapshot.id, snapshot.watchers)
|
|
675
|
+
writeRecord(snapshot.record)
|
|
676
|
+
} catch (rollback) { rollbackFailure ??= rollback }
|
|
677
|
+
}
|
|
678
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
679
|
+
const rollbackDetail = rollbackFailure instanceof Error ? `; rollback also failed: ${rollbackFailure.message}` : ''
|
|
680
|
+
throw new ResourceConflict(`reparent did not commit: ${detail}${rollbackDetail}`)
|
|
681
|
+
}
|
|
682
|
+
}))
|
|
683
|
+
})
|
|
684
|
+
for (const child of notify) {
|
|
685
|
+
const delivered = await sendText(parent, watchMessage(child), child.session)
|
|
686
|
+
if (!delivered.ok) throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`)
|
|
687
|
+
}
|
|
688
|
+
return { children, parent, notified: notify.map((child) => child.session) }
|
|
689
|
+
}
|
|
690
|
+
|
|
455
691
|
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
456
692
|
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
|
|
457
693
|
|
|
458
|
-
//
|
|
694
|
+
// tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
|
|
695
|
+
// 0x1f into `_`, while 3.4 turns a raw 0x1f into the printable escape `\037`. So the field separator is ASKED
|
|
696
|
+
// FOR as that printable text, which every supported version passes through untouched, and the format is built
|
|
697
|
+
// from the same constant the parser splits on: the two can no longer disagree about what tmux actually emits.
|
|
698
|
+
const TMUX_PANE_SEPARATOR = '\\037'
|
|
699
|
+
export const TMUX_PANE_FORMAT = `#{session_name}${TMUX_PANE_SEPARATOR}#{pane_pid}${TMUX_PANE_SEPARATOR}#{pane_title}`
|
|
700
|
+
|
|
701
|
+
// First pane per session wins; split only twice so titles may contain the field separator.
|
|
459
702
|
export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
|
|
460
703
|
const m = new Map<string, { panePid?: number; title?: string }>()
|
|
461
704
|
for (const line of out.split('\n')) {
|
|
462
705
|
if (!line) continue
|
|
463
|
-
|
|
706
|
+
// Accept the former tab shape for callers replaying old snapshots; tmux itself emits TMUX_PANE_SEPARATOR.
|
|
707
|
+
const separator = line.includes(TMUX_PANE_SEPARATOR) ? TMUX_PANE_SEPARATOR : '\t'
|
|
708
|
+
const t1 = line.indexOf(separator)
|
|
464
709
|
const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
|
|
465
710
|
if (!name || m.has(name)) continue // first pane per session wins
|
|
466
711
|
if (t1 < 0) { m.set(name, {}); continue }
|
|
467
|
-
const rest = line.slice(t1 +
|
|
468
|
-
const t2 = rest.indexOf(
|
|
712
|
+
const rest = line.slice(t1 + separator.length)
|
|
713
|
+
const t2 = rest.indexOf(separator)
|
|
469
714
|
const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
|
|
470
|
-
const title = t2 < 0 ? '' : rest.slice(t2 +
|
|
715
|
+
const title = t2 < 0 ? '' : rest.slice(t2 + separator.length)
|
|
471
716
|
m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
|
|
472
717
|
}
|
|
473
718
|
return m
|
|
@@ -498,13 +743,17 @@ export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean
|
|
|
498
743
|
return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
|
|
499
744
|
}
|
|
500
745
|
|
|
501
|
-
async function liveSnapshot(): Promise<LiveSnap> {
|
|
746
|
+
async function liveSnapshot(targetId?: string): Promise<LiveSnap> {
|
|
502
747
|
const windows = new Map<string, PaneProbe>()
|
|
503
748
|
const titles = new Map<string, string>()
|
|
504
749
|
let out: string
|
|
505
750
|
try {
|
|
506
751
|
// ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
|
|
507
|
-
|
|
752
|
+
// A target-scoped close probe avoids unrelated panes turning a safe close into a global timeout.
|
|
753
|
+
const args = targetId
|
|
754
|
+
? ['list-panes', '-t', targetId, '-F', TMUX_PANE_FORMAT]
|
|
755
|
+
: ['list-panes', '-a', '-F', TMUX_PANE_FORMAT]
|
|
756
|
+
out = await tmux(args, targetId ? TARGET_PROBE_TIMEOUT_MS : TMUX_PROBE_TIMEOUT_MS)
|
|
508
757
|
} catch (e) {
|
|
509
758
|
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
510
759
|
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
@@ -532,7 +781,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
532
781
|
// The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
|
|
533
782
|
// in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
|
|
534
783
|
const ids = [...windows.keys()]
|
|
535
|
-
|
|
784
|
+
// A burst of simultaneous Unix-socket connects can fill a Claude listener's accept backlog on macOS and
|
|
785
|
+
// turn every healthy socket into `unproven`. Keep the probe bounded while preserving the tri-state result.
|
|
786
|
+
const listening: Awaited<ReturnType<typeof rendezvousListening>>[] = []
|
|
787
|
+
for (let start = 0; start < ids.length; start += 2) {
|
|
788
|
+
listening.push(...await Promise.all(ids.slice(start, start + 2).map((id) => rendezvousListening(id))))
|
|
789
|
+
}
|
|
536
790
|
const sockets = new Set<string>()
|
|
537
791
|
const unproven = new Set<string>()
|
|
538
792
|
ids.forEach((id, i) => {
|
|
@@ -663,14 +917,30 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
663
917
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
664
918
|
}
|
|
665
919
|
|
|
920
|
+
// @@@ identity WITHOUT the gates - reviewPayload answers two different questions at once: who is this
|
|
921
|
+
// session (a store read, free) and how does its branch stand against main (ahead count, dirty scan, a
|
|
922
|
+
// merge-tree conflict probe — 646 ms and 8 git children on a far-diverged branch). A consumer that renders
|
|
923
|
+
// no gates strip should not buy the second one. The record already holds the identity half.
|
|
924
|
+
export type ReviewIdentity = { id: string; node: string | null; branch: string | null; label: string }
|
|
925
|
+
export function reviewIdentity(id: string): ReviewIdentity | null {
|
|
926
|
+
const rec = readRecord(id)
|
|
927
|
+
if (!rec) return null
|
|
928
|
+
return {
|
|
929
|
+
id,
|
|
930
|
+
node: rec.node,
|
|
931
|
+
branch: rec.branch,
|
|
932
|
+
label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
666
936
|
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
667
937
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
668
938
|
return {
|
|
669
|
-
id, node: null, branch: null, path: '', label,
|
|
939
|
+
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
670
940
|
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
671
941
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
672
942
|
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
673
|
-
activity: null, sortKey: null, archiveHazard: null,
|
|
943
|
+
activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
|
|
674
944
|
}
|
|
675
945
|
}
|
|
676
946
|
|
|
@@ -681,9 +951,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
681
951
|
const showActivity = lv === 'online'
|
|
682
952
|
const act = showActivity ? activity : null
|
|
683
953
|
const pp = prompt ? oneLinePreview(prompt) : null
|
|
684
|
-
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
954
|
+
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp }
|
|
685
955
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
686
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts),
|
|
956
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(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, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session) }
|
|
687
957
|
}
|
|
688
958
|
|
|
689
959
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
@@ -715,6 +985,14 @@ export async function sessionPrompt(id: string): Promise<string | null> {
|
|
|
715
985
|
// Preserve rows through a transient record-read failure; prune after the store entry disappears.
|
|
716
986
|
const lastKnownSession = new Map<string, Session>()
|
|
717
987
|
|
|
988
|
+
// A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
|
|
989
|
+
// id-addressed record detail, which reads the stored prompt itself and overrides this field — so the list
|
|
990
|
+
// never had a reader for it. Shipping it made the body grow with total ask LENGTH instead of session count
|
|
991
|
+
// (measured on the adopter-a board: 2192 KB of a 2218 KB default body, 27 KB of which was actual board data)
|
|
992
|
+
// and pinned the same bytes in `lastKnownSession` for the life of the process. The CREATE response keeps the
|
|
993
|
+
// full text: it is a receipt for one ask the caller just made, not a row in a list of many.
|
|
994
|
+
const boardRow = (s: Session): Session => { s.prompt = null; return s }
|
|
995
|
+
|
|
718
996
|
export async function listSessions(includeArchived = false): Promise<Session[]> {
|
|
719
997
|
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
720
998
|
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
@@ -762,7 +1040,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
|
|
|
762
1040
|
// A forced public liveness comes only from the shared record projection. Do not let live process/thread
|
|
763
1041
|
// evidence punch through it (including archive hazard repair).
|
|
764
1042
|
if (entry.kind === 'ok' && entry.liveness === 'offline') {
|
|
765
|
-
const pending = toSession(rec, 'offline', 'offline')
|
|
1043
|
+
const pending = boardRow(toSession(rec, 'offline', 'offline'))
|
|
766
1044
|
lastKnownSession.set(id, pending)
|
|
767
1045
|
return pending
|
|
768
1046
|
}
|
|
@@ -786,7 +1064,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
|
|
|
786
1064
|
const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
|
|
787
1065
|
const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
|
|
788
1066
|
const projectedLv = projected === rec ? liveness(rec, snap) : physical!
|
|
789
|
-
const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
|
|
1067
|
+
const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity))
|
|
790
1068
|
if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
|
|
791
1069
|
? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
|
|
792
1070
|
: hasValidColdProof(rec)
|
|
@@ -825,10 +1103,16 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
|
|
|
825
1103
|
export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
|
|
826
1104
|
export type ApiBaseInfo = { url: string; source: ApiBaseSource }
|
|
827
1105
|
const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
|
|
1106
|
+
|
|
1107
|
+
export function optionArgv(argv: readonly string[] = process.argv): readonly string[] {
|
|
1108
|
+
const delimiter = argv.indexOf('--')
|
|
1109
|
+
return delimiter < 0 ? argv : argv.slice(0, delimiter)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
828
1112
|
// the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
|
|
829
1113
|
// `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
|
|
830
1114
|
function explicitApiFlag(): string | null {
|
|
831
|
-
const argv =
|
|
1115
|
+
const argv = optionArgv()
|
|
832
1116
|
const ai = argv.indexOf('--api')
|
|
833
1117
|
if (ai >= 0) {
|
|
834
1118
|
const v = argv[ai + 1]
|
|
@@ -1405,7 +1689,7 @@ export class SessionCreateError extends Error {
|
|
|
1405
1689
|
this.name = 'SessionCreateError'
|
|
1406
1690
|
}
|
|
1407
1691
|
}
|
|
1408
|
-
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
|
|
1692
|
+
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal; base?: string | null }
|
|
1409
1693
|
type SessionCreateRequestOptions = {
|
|
1410
1694
|
requestKey?: string
|
|
1411
1695
|
signal?: AbortSignal
|
|
@@ -1454,12 +1738,16 @@ function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): v
|
|
|
1454
1738
|
export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
|
|
1455
1739
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
|
|
1456
1740
|
const input = body as Record<string, unknown>
|
|
1457
|
-
const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
|
|
1741
|
+
const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher', 'name', 'base'].includes(key)).sort()
|
|
1458
1742
|
if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
|
|
1459
1743
|
const prompt = typeof input.prompt === 'string' ? input.prompt : ''
|
|
1460
1744
|
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1461
1745
|
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1462
1746
|
const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
|
|
1747
|
+
if (input.name !== undefined && typeof input.name !== 'string') return { status: 400, error: 'session-create name must be a string' }
|
|
1748
|
+
const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim() : null
|
|
1749
|
+
if (input.base !== undefined && typeof input.base !== 'string') return { status: 400, error: 'session-create base must be a string' }
|
|
1750
|
+
const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
|
|
1463
1751
|
let key: string
|
|
1464
1752
|
try { key = normalizeCreateKey(options.requestKey) }
|
|
1465
1753
|
catch (error) {
|
|
@@ -1468,7 +1756,10 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
|
|
|
1468
1756
|
}
|
|
1469
1757
|
const requestDigest = digest(key)
|
|
1470
1758
|
const id = sessionIdForCreateKey(key)
|
|
1471
|
-
|
|
1759
|
+
// Keep no-name retries byte-compatible with pre-name receipts; an explicit non-empty name is one more
|
|
1760
|
+
// immutable creation input because it publishes the record's existing display override. `base` joins them
|
|
1761
|
+
// for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
|
|
1762
|
+
const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }))
|
|
1472
1763
|
const controller = new AbortController()
|
|
1473
1764
|
const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
|
|
1474
1765
|
if (options.signal?.aborted) cancel()
|
|
@@ -1478,7 +1769,7 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
|
|
|
1478
1769
|
traceSessionCreate(id, requestDigest, 'request', 'start')
|
|
1479
1770
|
try {
|
|
1480
1771
|
try {
|
|
1481
|
-
const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
|
|
1772
|
+
const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal })
|
|
1482
1773
|
traceSessionCreate(id, requestDigest, 'request', 'finish')
|
|
1483
1774
|
return { status: 201, session }
|
|
1484
1775
|
} catch (error) {
|
|
@@ -1527,15 +1818,16 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
|
|
|
1527
1818
|
return false
|
|
1528
1819
|
} finally { clearTimeout(timer) }
|
|
1529
1820
|
}
|
|
1530
|
-
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1821
|
+
export async function createSession(prompt: string, launcher?: string, name?: string, base?: string): Promise<Session> {
|
|
1531
1822
|
const parent = ownSessionId()
|
|
1532
1823
|
const requestKey = randomUUID()
|
|
1824
|
+
const body = { prompt, parent, launcher, ...(name !== undefined ? { name } : {}), ...(base !== undefined ? { base } : {}) }
|
|
1533
1825
|
const target = await apiBaseInfo()
|
|
1534
|
-
const
|
|
1826
|
+
const apiUrl = target.url
|
|
1535
1827
|
const refused = await probeSessionCreateAuthority(target)
|
|
1536
1828
|
if (refused) {
|
|
1537
1829
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1538
|
-
const fallback = await sessionCreateRequest(
|
|
1830
|
+
const fallback = await sessionCreateRequest(body, { requestKey })
|
|
1539
1831
|
if (fallback.status === 201) return fallback.session
|
|
1540
1832
|
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
|
|
1541
1833
|
error.name = 'BackendError'
|
|
@@ -1546,10 +1838,10 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
1546
1838
|
timer.unref?.()
|
|
1547
1839
|
let res: Response
|
|
1548
1840
|
try {
|
|
1549
|
-
res = await fetch(`${
|
|
1841
|
+
res = await fetch(`${apiUrl}/api/sessions`, {
|
|
1550
1842
|
method: 'POST',
|
|
1551
1843
|
headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
|
|
1552
|
-
body: JSON.stringify(
|
|
1844
|
+
body: JSON.stringify(body),
|
|
1553
1845
|
signal: controller.signal,
|
|
1554
1846
|
})
|
|
1555
1847
|
} catch (error) {
|
|
@@ -1669,6 +1961,23 @@ type SessionCandidateReceiptRead =
|
|
|
1669
1961
|
const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
|
|
1670
1962
|
const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
|
|
1671
1963
|
const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
|
|
1964
|
+
// The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
|
|
1965
|
+
// registering a session candidate. The receipt is written before `git worktree add` and retired only
|
|
1966
|
+
// after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
|
|
1967
|
+
export function pendingSessionCreateWorktreePaths(): Set<string> {
|
|
1968
|
+
const paths = new Set<string>()
|
|
1969
|
+
let entries: import('node:fs').Dirent[]
|
|
1970
|
+
try { entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true }) }
|
|
1971
|
+
catch { return paths }
|
|
1972
|
+
for (const entry of entries) {
|
|
1973
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
1974
|
+
try {
|
|
1975
|
+
const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8')) as Partial<SessionCandidateReceipt>
|
|
1976
|
+
if (typeof value.path === 'string' && value.path && typeof value.stage === 'string') paths.add(resolve(value.path))
|
|
1977
|
+
} catch { /* an in-flight atomic replace is not a candidate path */ }
|
|
1978
|
+
}
|
|
1979
|
+
return paths
|
|
1980
|
+
}
|
|
1672
1981
|
function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
|
|
1673
1982
|
const path = sessionCandidateReceiptPath(id)
|
|
1674
1983
|
if (!existsSync(path)) return { kind: 'absent' }
|
|
@@ -1796,8 +2105,8 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
|
|
|
1796
2105
|
return null
|
|
1797
2106
|
}
|
|
1798
2107
|
|
|
1799
|
-
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
|
|
1800
|
-
const { id, requestDigest, payloadHash, signal } = context
|
|
2108
|
+
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, name: string | null, context: SessionCreateContext): Promise<Session> {
|
|
2109
|
+
const { id, requestDigest, payloadHash, base, signal } = context
|
|
1801
2110
|
let phase: SessionCreatePhase = 'creation-lock'
|
|
1802
2111
|
let shouldDrain = false
|
|
1803
2112
|
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
@@ -1836,6 +2145,16 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1836
2145
|
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1837
2146
|
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1838
2147
|
const root = mainRoot()
|
|
2148
|
+
// An explicit base pins the fork point so a run is reproducible against a frozen commit instead of
|
|
2149
|
+
// whatever the source-of-truth branch has drifted to. Resolve it here, before any git mutation: an
|
|
2150
|
+
// unknown ref must fail the create request outright, never leave a half-made worktree behind.
|
|
2151
|
+
const startPoint = base ?? mainBranch()
|
|
2152
|
+
if (base) {
|
|
2153
|
+
const resolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]))
|
|
2154
|
+
if (!resolved.ok || !resolved.stdout.trim()) {
|
|
2155
|
+
throw new SessionCreateError('session_create_failed', phase, `session-create base does not name a commit: ${base}`, 400)
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
1839
2158
|
const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
|
|
1840
2159
|
const path = join(root, '.worktrees', slug)
|
|
1841
2160
|
const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
|
|
@@ -1857,7 +2176,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1857
2176
|
const resourceLock = sessionCandidateLockId(path, branch)
|
|
1858
2177
|
return await withRecordLock(resourceLock, async () => {
|
|
1859
2178
|
throwIfCreateAborted(signal, phase)
|
|
2179
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
|
|
1860
2180
|
let before = await sessionCandidateState(root, path, branch, signal)
|
|
2181
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
|
|
1861
2182
|
let storePresent = existsSync(sessionStoreDir(id))
|
|
1862
2183
|
const durable = readSessionCandidateReceipt(id)
|
|
1863
2184
|
if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
|
|
@@ -1889,7 +2210,12 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1889
2210
|
let published = false
|
|
1890
2211
|
try {
|
|
1891
2212
|
gitMutationStarted = true
|
|
1892
|
-
|
|
2213
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
|
|
2214
|
+
const added = await withGitAbortSignal(signal, () => gitTry(
|
|
2215
|
+
['-C', root, 'worktree', 'add', '-b', branch, path, startPoint],
|
|
2216
|
+
{ extraEnv: DEFER_FOOTPRINT_REFRESH },
|
|
2217
|
+
))
|
|
2218
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
|
|
1893
2219
|
if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
|
|
1894
2220
|
if (!added.ok || !existsSync(path)) {
|
|
1895
2221
|
throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
|
|
@@ -1897,14 +2223,17 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1897
2223
|
candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
|
|
1898
2224
|
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
1899
2225
|
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2226
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
|
|
1900
2227
|
seedWorktreeHostState(root, path)
|
|
2228
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
|
|
1901
2229
|
|
|
1902
2230
|
let rec: SessRec = {
|
|
1903
2231
|
session: id, governed: true, worktreePath: path, branch,
|
|
1904
|
-
node: ref || null, title, name
|
|
2232
|
+
node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
|
|
1905
2233
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
1906
2234
|
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
1907
2235
|
launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
|
|
2236
|
+
...(base ? { base } : {}),
|
|
1908
2237
|
}
|
|
1909
2238
|
owned.store = true
|
|
1910
2239
|
const dir = storeDir(id)
|
|
@@ -2254,6 +2583,8 @@ export type ReviewGates = {
|
|
|
2254
2583
|
}
|
|
2255
2584
|
export type ReviewPayload = {
|
|
2256
2585
|
id: string; node: string | null; branch: string | null
|
|
2586
|
+
branchHead: string // immutable session-branch object whose committed review facts describe
|
|
2587
|
+
baseHead: string // immutable canonical-base object from the same ref snapshot
|
|
2257
2588
|
label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
|
|
2258
2589
|
ahead: number // commits the node branch is ahead of main
|
|
2259
2590
|
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
@@ -2262,6 +2593,23 @@ export type ReviewPayload = {
|
|
|
2262
2593
|
proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
|
|
2263
2594
|
}
|
|
2264
2595
|
|
|
2596
|
+
type ReviewHeadPair = { branchHead: string; baseHead: string }
|
|
2597
|
+
|
|
2598
|
+
async function reviewHeadPair(root: string, branch: string, base: string): Promise<ReviewHeadPair> {
|
|
2599
|
+
const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`
|
|
2600
|
+
const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef])
|
|
2601
|
+
const refs = new Map<string, string>()
|
|
2602
|
+
for (const line of output.split('\n')) {
|
|
2603
|
+
const at = line.indexOf('\0')
|
|
2604
|
+
if (at > 0) refs.set(line.slice(0, at), line.slice(at + 1).trim())
|
|
2605
|
+
}
|
|
2606
|
+
const branchHead = refs.get(branchRef), baseHead = refs.get(baseRef)
|
|
2607
|
+
if (!branchHead || !baseHead || !isGitObjectId(root, branchHead) || !isGitObjectId(root, baseHead)) {
|
|
2608
|
+
throw new ResourceConflict(`review head pair is unproven: ${branchRef} or ${baseRef} is missing or not a native Git object id`)
|
|
2609
|
+
}
|
|
2610
|
+
return { branchHead, baseHead }
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2265
2613
|
// @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
|
|
2266
2614
|
// (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
|
|
2267
2615
|
// it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
|
|
@@ -2308,19 +2656,28 @@ async function lintGate(): Promise<ReviewGates['lint']> {
|
|
|
2308
2656
|
export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
2309
2657
|
const wt = await findWorktree(id)
|
|
2310
2658
|
if (!wt) return null
|
|
2659
|
+
if (!wt.rec.governed || !wt.branch) throw new ResourceConflict(`session ${id} has no governed branch to review`)
|
|
2311
2660
|
const base = mainBranch()
|
|
2661
|
+
const { branchHead, baseHead } = await reviewHeadPair(wt.path, wt.branch, base)
|
|
2312
2662
|
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
2313
|
-
gitA(['-C', wt.path, 'rev-list', '--count', `${
|
|
2663
|
+
gitA(['-C', wt.path, 'rev-list', '--count', `${baseHead}..${branchHead}`]),
|
|
2314
2664
|
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
2315
|
-
mergeBaseDiff(wt.path,
|
|
2316
|
-
mergeConflicts(wt.path,
|
|
2665
|
+
mergeBaseDiff(wt.path, baseHead, branchHead),
|
|
2666
|
+
mergeConflicts(wt.path, baseHead, branchHead),
|
|
2317
2667
|
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
2318
2668
|
])
|
|
2669
|
+
const settledPair = await reviewHeadPair(wt.path, wt.branch, base)
|
|
2670
|
+
if (settledPair.branchHead !== branchHead || settledPair.baseHead !== baseHead) {
|
|
2671
|
+
throw new ResourceConflict(
|
|
2672
|
+
`review head pair changed while assembling: started branch ${branchHead} / base ${baseHead}, ended branch ${settledPair.branchHead} / base ${settledPair.baseHead}`,
|
|
2673
|
+
'session_review_head_changed',
|
|
2674
|
+
)
|
|
2675
|
+
}
|
|
2319
2676
|
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
2320
2677
|
// path is genuine work — this is just the total uncommitted count.
|
|
2321
2678
|
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
2322
2679
|
return {
|
|
2323
|
-
id, node: wt.rec.node, branch: wt.branch,
|
|
2680
|
+
id, node: wt.rec.node, branch: wt.branch, branchHead, baseHead,
|
|
2324
2681
|
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
2325
2682
|
ahead: Number(aheadOut.trim()) || 0,
|
|
2326
2683
|
dirtyNonRuntime, diff,
|
|
@@ -2329,32 +2686,204 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
2329
2686
|
}
|
|
2330
2687
|
}
|
|
2331
2688
|
|
|
2332
|
-
|
|
2333
|
-
|
|
2689
|
+
// @@@ shAscii - a 7-bit ASCII shell word yielding EXACTLY these bytes. A dispatched prompt crosses a control
|
|
2690
|
+
// channel, the agent's own tool call, and a terminal before a shell parses it, and a byte above 0x7F can be
|
|
2691
|
+
// dropped, replaced by U+FFFD, or truncate the line at any of those hops — the measured field failure. Bytes
|
|
2692
|
+
// that are already safe ASCII stay literal (an ASCII repo's prompt is byte-identical to before); anything
|
|
2693
|
+
// else is rebuilt from POSIX `printf %b` octal escapes, taken from the RAW bytes with no normalization.
|
|
2694
|
+
function shAscii(value: string | Buffer): string {
|
|
2695
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
|
|
2696
|
+
const safe = (b: number) => b >= 0x20 && b <= 0x7e
|
|
2697
|
+
if (bytes.every(safe)) return shQuote(bytes.toString('latin1'))
|
|
2698
|
+
let escaped = ''
|
|
2699
|
+
for (const b of bytes) escaped += safe(b) && b !== 0x27 && b !== 0x5c ? String.fromCharCode(b) : `\\0${b.toString(8).padStart(3, '0')}`
|
|
2700
|
+
return `"$(printf '%b' '${escaped}')"`
|
|
2701
|
+
}
|
|
2702
|
+
const hexBytes = (value: string | Buffer): string => (Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')).toString('hex')
|
|
2703
|
+
|
|
2704
|
+
// @@@ mergePrompt - every gate states its own verdict, in ASCII. Two things were wrong with a bare `&&` chain
|
|
2705
|
+
// of `test`s. It is silent on BOTH outcomes, so an agent shown "no output" cannot tell a held gate from a
|
|
2706
|
+
// broken one and conservatively refuses to land forever; and it compared unicode ref/path bytes as shell
|
|
2707
|
+
// strings, which only works if every hop to the executor carried them intact. So each item now reports its
|
|
2708
|
+
// own actual-vs-expected value, only the whole conjunction emits the success token, and every value that can
|
|
2709
|
+
// carry a byte above 0x7F travels as hex (compared straight off the pipe, before any shell string layer) or
|
|
2710
|
+
// as a printf-escaped literal. The conjunction itself is unchanged: all five, resp. three, checks together.
|
|
2711
|
+
function mergePrompt(mainPath: string, worktreePath: string, worktreeTop: Buffer, branch: string, base: string, expectedBranchHead: string, expectedBaseHead: string, reason: string): string {
|
|
2712
|
+
const mainQ = shQuote(mainPath)
|
|
2713
|
+
const mainA = shAscii(mainPath), worktreeA = shAscii(worktreePath)
|
|
2714
|
+
const refA = shAscii(`refs/heads/${branch}`), baseRefA = shAscii(`refs/heads/${base}`), baseA = shAscii(base)
|
|
2715
|
+
const topHexQ = shQuote(hexBytes(worktreeTop)), refHexQ = shQuote(hexBytes(`refs/heads/${branch}`))
|
|
2716
|
+
const reviewedQ = shQuote(expectedBranchHead), baseHeadQ = shQuote(expectedBaseHead)
|
|
2717
|
+
const messageA = shAscii(`merge ${branch}: ${reason}`)
|
|
2718
|
+
const hexPipe = `| tr -d '\\n' | od -An -tx1 | tr -d ' \\n'`
|
|
2334
2719
|
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` +
|
|
2720
|
+
`0. Re-prove the REVIEWED generation BEFORE changing anything. All five checks must hold together, and the block SAYS SO: reading \`REVIEWED_GENERATION_OK\` is the ONLY pass. A \`REVIEWED_GENERATION_FAIL <n>/<item>\` line — or no output at all — is a FAIL: STOP, report the stale review naming that item, and do not sync or land. (Detached HEAD, another checked-out branch, a moved/missing branch/base ref, or any OID outside the reviewed pair each surface as one of the five items.) Run the block AS WRITTEN — it is pure ASCII on purpose, and items 1 and 2 compare hex so a retyped or re-encoded copy cannot change what they mean:\n` +
|
|
2721
|
+
` wt=${worktreeA}; main_ck=${mainA}; ref=${refA}; base_ref=${baseRefA}\n` +
|
|
2722
|
+
` want_top=${topHexQ}; want_ref=${refHexQ}; want_reviewed=${reviewedQ}; want_base=${baseHeadQ}\n` +
|
|
2723
|
+
` g1=$(git -C "$wt" rev-parse --show-toplevel ${hexPipe}); test "$g1" = "$want_top" || echo "REVIEWED_GENERATION_FAIL 1/toplevel: hex [$g1] != [$want_top]"\n` +
|
|
2724
|
+
` g2=$(git -C "$wt" symbolic-ref --quiet HEAD ${hexPipe}); test "$g2" = "$want_ref" || echo "REVIEWED_GENERATION_FAIL 2/symbolic: hex [$g2] != [$want_ref]"\n` +
|
|
2725
|
+
` g3=$(git -C "$wt" rev-parse HEAD); test "$g3" = "$want_reviewed" || echo "REVIEWED_GENERATION_FAIL 3/wtHEAD: [$g3] != [$want_reviewed]"\n` +
|
|
2726
|
+
` g4=$(git -C "$main_ck" show-ref --verify --hash "$ref"); test "$g4" = "$want_reviewed" || echo "REVIEWED_GENERATION_FAIL 4/mainref: [$g4] != [$want_reviewed]"\n` +
|
|
2727
|
+
` g5=$(git -C "$main_ck" show-ref --verify --hash "$base_ref"); test "$g5" = "$want_base" || echo "REVIEWED_GENERATION_FAIL 5/baseref: [$g5] != [$want_base]"\n` +
|
|
2728
|
+
` test "$g1" = "$want_top" && test "$g2" = "$want_ref" && test "$g3" = "$want_reviewed" && test "$g4" = "$want_reviewed" && test "$g5" = "$want_base" && echo REVIEWED_GENERATION_OK\n` +
|
|
2335
2729
|
`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` +
|
|
2336
|
-
`2.
|
|
2730
|
+
`2. Freeze the TESTED result immediately before landing and merge that exact object, never a moving branch name. Same rules as step 0 — run it as written, and \`LANDING_MERGED <oid>\` is the ONLY proof it landed; it prints only after all three guards held AND the merge itself succeeded:\n` +
|
|
2731
|
+
` wt=${worktreeA}; main_ck=${mainA}; ref=${refA}; base_br=${baseA}; msg=${messageA}; want_ref=${refHexQ}\n` +
|
|
2732
|
+
` candidate=$(git -C "$wt" rev-parse HEAD)\n` +
|
|
2733
|
+
` c1=$(git -C "$wt" symbolic-ref --quiet HEAD ${hexPipe}); test "$c1" = "$want_ref" || echo "LANDING_FAIL 1/symbolic: hex [$c1] != [$want_ref]"\n` +
|
|
2734
|
+
` c2=$(git -C "$main_ck" show-ref --verify --hash "$ref"); test "$c2" = "$candidate" || echo "LANDING_FAIL 2/mainref: [$c2] != [$candidate]"\n` +
|
|
2735
|
+
` c3=0; git -C "$main_ck" merge-base --is-ancestor "$base_br" "$candidate" || { c3=1; echo "LANDING_FAIL 3/ancestor: [$candidate] does not contain the base branch; go back to step 1"; }\n` +
|
|
2736
|
+
` test "$c1" = "$want_ref" && test "$c2" = "$candidate" && test "$c3" = 0 && git -C "$main_ck" merge --no-ff -m "$msg" "$candidate" && echo "LANDING_MERGED $candidate"\n` +
|
|
2737
|
+
` No \`LANDING_MERGED\` line means nothing landed: a \`LANDING_FAIL\` names which guard, and its absence after the guards held means the merge itself failed. Either way ${base} or the branch moved while you tested — go back to step 0/review instead of landing.\n` +
|
|
2337
2738
|
`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. ` +
|
|
2338
|
-
`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 ${
|
|
2739
|
+
`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 ${mainQ} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
|
|
2339
2740
|
`5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
2340
2741
|
}
|
|
2341
2742
|
|
|
2342
|
-
|
|
2743
|
+
export type MergeSessionResult =
|
|
2744
|
+
| { dispatched: true; replayed?: boolean; expectedBranchHead: string; expectedBaseHead: string }
|
|
2745
|
+
| { dispatched: false; reason: string; code?: 'session_merge_invalid_request' | 'session_merge_key_reused' | 'session_merge_head_changed' | 'session_merge_branch_unproven' | 'session_merge_not_proposed'; status?: 400 | 409 }
|
|
2746
|
+
export type MergeSessionOptions = { requestKey?: string; expectedBranchHead?: string; expectedBaseHead?: string }
|
|
2747
|
+
|
|
2748
|
+
function normalizeMergeKey(raw: string | undefined): string | null {
|
|
2749
|
+
if (raw === undefined) return null
|
|
2750
|
+
const key = raw.trim()
|
|
2751
|
+
if (!key || key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) return null
|
|
2752
|
+
return key
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
async function proveMergeBranchIdentity(worktreePath: string, branch: string, base: string): Promise<{ ok: true; branchHead: string; baseHead: string; top: string } | { ok: false; reason: string }> {
|
|
2756
|
+
const [top, symbolic, head, pair] = await Promise.all([
|
|
2757
|
+
gitTry(['-C', worktreePath, 'rev-parse', '--show-toplevel']),
|
|
2758
|
+
gitTry(['-C', worktreePath, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
2759
|
+
gitTry(['-C', worktreePath, 'rev-parse', 'HEAD']),
|
|
2760
|
+
reviewHeadPair(worktreePath, branch, base).then((value) => ({ ok: true as const, value }), (error) => ({ ok: false as const, error })),
|
|
2761
|
+
])
|
|
2762
|
+
if (!top.ok || !symbolic.ok || !head.ok || !pair.ok) {
|
|
2763
|
+
return { ok: false, reason: `branch identity is unreadable${!symbolic.ok ? ' (worktree HEAD is detached or not symbolic)' : ''}` }
|
|
2764
|
+
}
|
|
2765
|
+
let actualTop = top.stdout.trim(), expectedTop = worktreePath
|
|
2766
|
+
try { actualTop = realpathSync(actualTop) } catch { /* comparison reports the missing/moved root */ }
|
|
2767
|
+
try { expectedTop = realpathSync(expectedTop) } catch { /* comparison reports the missing/moved root */ }
|
|
2768
|
+
if (actualTop !== expectedTop) return { ok: false, reason: `worktree top-level is ${actualTop}, expected ${expectedTop}` }
|
|
2769
|
+
if (symbolic.stdout.trim() !== branch) return { ok: false, reason: `worktree checked out ${symbolic.stdout.trim() || '(detached)'}, expected ${branch}` }
|
|
2770
|
+
const worktreeHead = head.stdout.trim()
|
|
2771
|
+
if (worktreeHead !== pair.value.branchHead) return { ok: false, reason: `worktree HEAD ${worktreeHead} does not match stored branch ${branch} at ${pair.value.branchHead}` }
|
|
2772
|
+
return { ok: true, ...pair.value, top: actualTop }
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
type DispatchDelivery = NonNullable<SentDispatchReceipt['delivery']>
|
|
2776
|
+
function keyedPendingMessage(receipt: SentDispatchReceipt, mid: string, delivery: DispatchDelivery): PendingMessage {
|
|
2777
|
+
return {
|
|
2778
|
+
mid,
|
|
2779
|
+
text: delivery.text,
|
|
2780
|
+
from: delivery.from,
|
|
2781
|
+
dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
async function acceptedMergeDispatch(id: string, idempotency: SentDispatchReceipt): Promise<SentDispatchState | null> {
|
|
2786
|
+
return withRecordLock(id, async () => withDeliveryLocks([id], async () => {
|
|
2787
|
+
const prior = sentDispatchReceipt(id, idempotency.operation, idempotency.requestDigest)
|
|
2788
|
+
if (prior?.payloadHash === idempotency.payloadHash && prior.delivery && !prior.delivered) {
|
|
2789
|
+
ensurePendingWhileLocked(id, keyedPendingMessage(idempotency, prior.mid, prior.delivery))
|
|
2790
|
+
}
|
|
2791
|
+
return prior
|
|
2792
|
+
}))
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
async function replayAcceptedMerge(id: string, idempotency: SentDispatchReceipt, prior: SentDispatchState): Promise<void> {
|
|
2796
|
+
// Old receipts predate recoverable delivery bytes and are already accepted history. Settled receipts are
|
|
2797
|
+
// likewise response-only: replay must not reopen or otherwise mutate a session after its later lifecycle.
|
|
2798
|
+
if (!prior.delivery || prior.delivered) return
|
|
2799
|
+
await drainSession(id)
|
|
2800
|
+
const current = sentDispatchReceipt(id, idempotency.operation, idempotency.requestDigest)
|
|
2801
|
+
if (current?.delivered) return
|
|
2802
|
+
await resumeSession(id, { guard: false })
|
|
2803
|
+
await drainSession(id)
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
async function mergeSessionUnlocked(id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> {
|
|
2807
|
+
const requestKey = normalizeMergeKey(options.requestKey)
|
|
2808
|
+
if (!requestKey) {
|
|
2809
|
+
return { dispatched: false, reason: 'Idempotency-Key must be 1-128 visible ASCII characters', code: 'session_merge_invalid_request', status: 400 }
|
|
2810
|
+
}
|
|
2811
|
+
const expectedBranchHead = options.expectedBranchHead, expectedBaseHead = options.expectedBaseHead
|
|
2812
|
+
if (expectedBranchHead === undefined || expectedBaseHead === undefined) {
|
|
2813
|
+
return { dispatched: false, reason: 'expectedBranchHead and expectedBaseHead are required with Idempotency-Key', code: 'session_merge_invalid_request', status: 400 }
|
|
2814
|
+
}
|
|
2815
|
+
const main = mainRoot()
|
|
2816
|
+
if (!isGitObjectId(main, expectedBranchHead) || !isGitObjectId(main, expectedBaseHead)) {
|
|
2817
|
+
return { dispatched: false, reason: 'expectedBranchHead and expectedBaseHead must be full lowercase native Git object ids', code: 'session_merge_invalid_request', status: 400 }
|
|
2818
|
+
}
|
|
2819
|
+
const idempotency = {
|
|
2820
|
+
operation: 'merge' as const,
|
|
2821
|
+
requestDigest: digest(`spexcode-session-merge\0${requestKey}`),
|
|
2822
|
+
payloadHash: digest(JSON.stringify({ expectedBranchHead, expectedBaseHead })),
|
|
2823
|
+
}
|
|
2824
|
+
const prior = await acceptedMergeDispatch(id, idempotency)
|
|
2825
|
+
if (prior) {
|
|
2826
|
+
if (prior.payloadHash !== idempotency.payloadHash) {
|
|
2827
|
+
return { dispatched: false, reason: 'Idempotency-Key is already bound to another session-merge payload', code: 'session_merge_key_reused', status: 409 }
|
|
2828
|
+
}
|
|
2829
|
+
await replayAcceptedMerge(id, idempotency, prior)
|
|
2830
|
+
return { dispatched: true, replayed: true, expectedBranchHead, expectedBaseHead }
|
|
2831
|
+
}
|
|
2832
|
+
const [branchObject, baseObject] = await Promise.all([
|
|
2833
|
+
gitTry(['-C', main, 'cat-file', '-e', `${expectedBranchHead}^{commit}`]),
|
|
2834
|
+
gitTry(['-C', main, 'cat-file', '-e', `${expectedBaseHead}^{commit}`]),
|
|
2835
|
+
])
|
|
2836
|
+
if (!branchObject.ok || !baseObject.ok) {
|
|
2837
|
+
return {
|
|
2838
|
+
dispatched: false,
|
|
2839
|
+
reason: `reviewed heads are missing or are not commits: branch ${expectedBranchHead} / base ${expectedBaseHead}`,
|
|
2840
|
+
code: 'session_merge_head_changed',
|
|
2841
|
+
status: 409,
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2343
2844
|
const wt = await findWorktree(id)
|
|
2344
2845
|
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
2345
|
-
const branch = wt.branch,
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s',
|
|
2846
|
+
const branch = wt.branch, base = mainBranch()
|
|
2847
|
+
// Raw bytes, not a decoded-then-re-encoded string: the gate's expected hex must be what the filesystem
|
|
2848
|
+
// holds, so no normalization can slip between what we promise and what git will print.
|
|
2849
|
+
let worktreeTop = Buffer.from(wt.path, 'utf8')
|
|
2850
|
+
try { worktreeTop = realpathSync(wt.path, { encoding: 'buffer' }) } catch { /* the locked proof reports the vanished worktree */ }
|
|
2851
|
+
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', expectedBranchHead])).trim()
|
|
2351
2852
|
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
2352
|
-
const r = await sendText(id, mergePrompt(main, branch, reason)
|
|
2353
|
-
|
|
2354
|
-
|
|
2853
|
+
const r = await sendText(id, mergePrompt(main, wt.path, worktreeTop, branch, base, expectedBranchHead, expectedBaseHead, reason), undefined, {
|
|
2854
|
+
idempotency,
|
|
2855
|
+
deferDrain: true,
|
|
2856
|
+
acceptGuard: async (rec) => {
|
|
2857
|
+
const conflict = (message: string, code: NonNullable<Extract<MergeSessionResult, { dispatched: false }>['code']>) =>
|
|
2858
|
+
Object.assign(new ResourceConflict(message), { code })
|
|
2859
|
+
if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
|
|
2860
|
+
throw conflict(`session ${id} is not a governed awaiting merge proposal`, 'session_merge_not_proposed')
|
|
2861
|
+
}
|
|
2862
|
+
if (rec.worktreePath !== wt.path || rec.branch !== branch) {
|
|
2863
|
+
throw conflict(`session branch identity changed before merge acceptance`, 'session_merge_branch_unproven')
|
|
2864
|
+
}
|
|
2865
|
+
const proof = await proveMergeBranchIdentity(rec.worktreePath, branch, base)
|
|
2866
|
+
if (!proof.ok) throw conflict(`session branch identity is unproven: ${proof.reason}`, 'session_merge_branch_unproven')
|
|
2867
|
+
if (proof.branchHead !== expectedBranchHead || proof.baseHead !== expectedBaseHead) {
|
|
2868
|
+
throw conflict(`reviewed heads changed: expected branch ${expectedBranchHead} / base ${expectedBaseHead}, found branch ${proof.branchHead} / base ${proof.baseHead}`, 'session_merge_head_changed')
|
|
2869
|
+
}
|
|
2870
|
+
},
|
|
2871
|
+
})
|
|
2872
|
+
if (r.code === 'dispatch_key_reused') {
|
|
2873
|
+
return { dispatched: false, reason: 'Idempotency-Key is already bound to another session-merge payload', code: 'session_merge_key_reused', status: 409 }
|
|
2874
|
+
}
|
|
2875
|
+
if (r.code === 'session_merge_not_proposed' || r.code === 'session_merge_branch_unproven' || r.code === 'session_merge_head_changed') {
|
|
2876
|
+
return { dispatched: false, reason: r.error || 'merge authority refused', code: r.code, status: 409 }
|
|
2877
|
+
}
|
|
2878
|
+
if (!r.ok) return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' }
|
|
2879
|
+
// Acceptance is already durable. Ensure-live follows it so no relaunch/state mutation can happen before the
|
|
2880
|
+
// reviewed CAS; a failed relaunch leaves the one queued debt for the ordinary delivery supervisor.
|
|
2881
|
+
await resumeSession(id, { guard: false })
|
|
2882
|
+
await drainSession(id)
|
|
2883
|
+
return { dispatched: true, replayed: r.replayed === true, expectedBranchHead, expectedBaseHead }
|
|
2355
2884
|
}
|
|
2356
|
-
export const mergeSession = (id: string
|
|
2357
|
-
mergeSessionUnlocked(id)
|
|
2885
|
+
export const mergeSession = (id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> =>
|
|
2886
|
+
mergeSessionUnlocked(id, options)
|
|
2358
2887
|
|
|
2359
2888
|
// @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
|
|
2360
2889
|
// process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
|
|
@@ -2368,6 +2897,12 @@ export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?
|
|
|
2368
2897
|
// adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
|
|
2369
2898
|
const AGENT_EXIT_GRACE_MS = 3000
|
|
2370
2899
|
type LeafIdentity = { pid: number; startToken: string; ownerNeedle: string }
|
|
2900
|
+
type LeafIdentityObservation =
|
|
2901
|
+
| { state: 'missing' }
|
|
2902
|
+
| { state: 'dead'; pid: number }
|
|
2903
|
+
| { state: 'owned'; identity: LeafIdentity }
|
|
2904
|
+
| { state: 'unrelated'; pid: number; startToken: string }
|
|
2905
|
+
| { state: 'unknown'; pid?: number; reason: string }
|
|
2371
2906
|
async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
|
|
2372
2907
|
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2373
2908
|
if (pid !== leaf.pid)
|
|
@@ -2422,28 +2957,48 @@ const leafAlive = (pid: number): boolean => {
|
|
|
2422
2957
|
catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
|
|
2423
2958
|
}
|
|
2424
2959
|
|
|
2960
|
+
// One identity seam serves both signal teardown and cold retirement. A PID is only a locator: a live process
|
|
2961
|
+
// becomes target-owned when its immutable start identity is readable AND its argv carries the harness-owned
|
|
2962
|
+
// native identity. A live PID with a proven different argv is a stale artifact, while malformed/unreadable
|
|
2963
|
+
// evidence remains unknown and therefore blocks every destructive path.
|
|
2964
|
+
async function inspectSessionLeafIdentity(id: string, rec: SessRec): Promise<LeafIdentityObservation> {
|
|
2965
|
+
if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter') return { state: 'missing' }
|
|
2966
|
+
const path = sessionArtifactPath(id, 'agent.pid')
|
|
2967
|
+
let raw: string
|
|
2968
|
+
try { raw = readFileSync(path, 'utf8') }
|
|
2969
|
+
catch (error) {
|
|
2970
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return { state: 'missing' }
|
|
2971
|
+
return { state: 'unknown', reason: `leaf PID artifact is unreadable (${error instanceof Error ? error.message : String(error)})` }
|
|
2972
|
+
}
|
|
2973
|
+
const pid = Number(raw.trim())
|
|
2974
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return { state: 'unknown', reason: 'leaf PID artifact is malformed' }
|
|
2975
|
+
const startToken = processStartToken(pid)
|
|
2976
|
+
if (!startToken) return leafAlive(pid) ? { state: 'unknown', pid, reason: `leaf PID ${pid} is alive but its process-start identity is unreadable` } : { state: 'dead', pid }
|
|
2977
|
+
const ownerNeedle = harnessById(rec.harness || defaultHarness.id).leafOwnerNeedle?.(rec)
|
|
2978
|
+
if (!ownerNeedle) return { state: 'unknown', pid, reason: `no exact harness identity is registered for leaf PID ${pid}` }
|
|
2979
|
+
let argv: string
|
|
2980
|
+
try { argv = (await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' })).stdout.trim() }
|
|
2981
|
+
catch { return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is unreadable` } }
|
|
2982
|
+
if (!argv) return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is empty` }
|
|
2983
|
+
const endToken = processStartToken(pid)
|
|
2984
|
+
if (!endToken || endToken !== startToken)
|
|
2985
|
+
return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity changed during ownership read` }
|
|
2986
|
+
if (argv.includes(ownerNeedle)) return { state: 'owned', identity: { pid, startToken, ownerNeedle } }
|
|
2987
|
+
return { state: 'unrelated', pid, startToken }
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2425
2990
|
async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
// record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
|
|
2430
|
-
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2431
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
2991
|
+
if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter') return null
|
|
2992
|
+
const observed = await inspectSessionLeafIdentity(id, rec)
|
|
2993
|
+
if (observed.state === 'missing') {
|
|
2432
2994
|
if (rec.stopped || rec.status === 'queued') return null
|
|
2433
2995
|
throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
|
|
2434
2996
|
}
|
|
2435
|
-
|
|
2436
|
-
if (
|
|
2437
|
-
|
|
2438
|
-
throw new ResourceConflict(`refusing to stop ${id}:
|
|
2439
|
-
}
|
|
2440
|
-
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2441
|
-
const ownerNeedle = harness.leafOwnerNeedle?.(rec)
|
|
2442
|
-
if (!ownerNeedle)
|
|
2443
|
-
throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
|
|
2444
|
-
if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
|
|
2445
|
-
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
|
|
2446
|
-
return { pid, startToken, ownerNeedle }
|
|
2997
|
+
if (observed.state === 'dead') return null
|
|
2998
|
+
if (observed.state === 'owned') return observed.identity
|
|
2999
|
+
if (observed.state === 'unrelated')
|
|
3000
|
+
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${observed.pid}@${observed.startToken} does not prove argv ownership`)
|
|
3001
|
+
throw new ResourceConflict(`refusing to stop ${id}: ${observed.reason}`)
|
|
2447
3002
|
}
|
|
2448
3003
|
|
|
2449
3004
|
async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
|
|
@@ -2508,7 +3063,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2508
3063
|
// A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
|
|
2509
3064
|
// adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
|
|
2510
3065
|
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
2511
|
-
const proofSnap = await liveSnapshot()
|
|
3066
|
+
const proofSnap = await liveSnapshot(id)
|
|
2512
3067
|
if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
|
|
2513
3068
|
const proofLv = h.runtimeOwnership === 'adapter'
|
|
2514
3069
|
? (proofSnap.windows.has(id) ? 'online' : 'offline')
|
|
@@ -2545,7 +3100,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2545
3100
|
if (!wt) return false
|
|
2546
3101
|
}
|
|
2547
3102
|
|
|
2548
|
-
const snap = await liveSnapshot()
|
|
3103
|
+
const snap = await liveSnapshot(id)
|
|
2549
3104
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
|
|
2550
3105
|
const lv = h.runtimeOwnership === 'adapter'
|
|
2551
3106
|
? 'offline'
|
|
@@ -2554,6 +3109,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2554
3109
|
throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
|
|
2555
3110
|
// The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
|
|
2556
3111
|
// ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
|
|
3112
|
+
assertSessionOwnerSafe(id, h.id)
|
|
2557
3113
|
const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
|
|
2558
3114
|
if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
|
|
2559
3115
|
// Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
|
|
@@ -2568,7 +3124,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2568
3124
|
coldCommitted = true
|
|
2569
3125
|
const latest = readRecord(id)
|
|
2570
3126
|
if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
|
|
2571
|
-
const finalSnap = await liveSnapshot()
|
|
3127
|
+
const finalSnap = await liveSnapshot(id)
|
|
2572
3128
|
if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
|
|
2573
3129
|
const finalLv = h.runtimeOwnership === 'adapter'
|
|
2574
3130
|
? (finalSnap.windows.has(id) ? 'online' : 'offline')
|
|
@@ -2617,14 +3173,16 @@ async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void>
|
|
|
2617
3173
|
if (rec.adapterRecovery)
|
|
2618
3174
|
throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
|
|
2619
3175
|
|
|
2620
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
3176
|
+
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
|
|
2621
3177
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2622
3178
|
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
|
|
2623
3179
|
if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
|
|
2624
3180
|
if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
|
|
2625
|
-
const
|
|
2626
|
-
if (
|
|
2627
|
-
throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${pid} is live or recycled; ownership is ambiguous`)
|
|
3181
|
+
const leaf = await inspectSessionLeafIdentity(id, rec)
|
|
3182
|
+
if (leaf.state === 'owned')
|
|
3183
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`)
|
|
3184
|
+
if (leaf.state === 'unknown')
|
|
3185
|
+
throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`)
|
|
2628
3186
|
|
|
2629
3187
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2630
3188
|
if (harness.coldRetirementPreflight) {
|
|
@@ -2661,7 +3219,7 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
2661
3219
|
if (rec.adapterRecovery || launching.has(id))
|
|
2662
3220
|
throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
|
|
2663
3221
|
|
|
2664
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
3222
|
+
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
|
|
2665
3223
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2666
3224
|
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
|
|
2667
3225
|
if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
|
|
@@ -2693,15 +3251,21 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
2693
3251
|
}
|
|
2694
3252
|
}
|
|
2695
3253
|
|
|
2696
|
-
async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
|
|
3254
|
+
async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }, source: CloseSource): Promise<boolean> {
|
|
2697
3255
|
const root = mainRoot()
|
|
2698
3256
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
|
|
2699
3257
|
if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
|
|
2700
3258
|
const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
|
|
3259
|
+
const retired = !wt.rec.archived && !!retirementReason(wt.rec)
|
|
3260
|
+
// A retired row has already lost its worktree; close is its explicit record-only terminal cleanup.
|
|
3261
|
+
if (!retired) {
|
|
3262
|
+
if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
|
|
3263
|
+
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
3264
|
+
else throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`)
|
|
3265
|
+
}
|
|
3266
|
+
// The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
|
|
2701
3267
|
if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
|
|
2702
|
-
|
|
2703
|
-
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
2704
|
-
else await stopAgentProcess(id, wt.rec)
|
|
3268
|
+
appendCloseLedger(id, wt.rec, source)
|
|
2705
3269
|
let slot: string | null = null
|
|
2706
3270
|
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
2707
3271
|
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
@@ -2726,13 +3290,16 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
|
|
|
2726
3290
|
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
|
|
2727
3291
|
catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
2728
3292
|
if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
|
|
3293
|
+
// The close still owns this sender's record lock. Marking after its store is gone lets any send already
|
|
3294
|
+
// admitted finish before close returns, while every later send and every retry sweep sees terminal output.
|
|
3295
|
+
revokeSenderDelivery(id)
|
|
2729
3296
|
if (closesCodexBinding && wt.rec.harnessSessionId) {
|
|
2730
3297
|
bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
|
|
2731
3298
|
}
|
|
2732
3299
|
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
2733
3300
|
return true
|
|
2734
3301
|
}
|
|
2735
|
-
async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
3302
|
+
async function closeSessionUnlocked(id: string, source: CloseSource): Promise<boolean> {
|
|
2736
3303
|
let wt: { path: string; branch: string | null; rec: SessRec } | null = null
|
|
2737
3304
|
try { wt = await findWorktree(id) }
|
|
2738
3305
|
catch (e) {
|
|
@@ -2749,13 +3316,30 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
|
2749
3316
|
`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.`)
|
|
2750
3317
|
}
|
|
2751
3318
|
if (!wt) return false
|
|
3319
|
+
if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
|
|
3320
|
+
// A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
|
|
3321
|
+
// remains non-destructive while a turn is active; close already means discard this session's work.
|
|
3322
|
+
const harness = harnessById(wt.rec.harness || defaultHarness.id)
|
|
3323
|
+
assertSessionOwnerSafe(id, harness.id)
|
|
3324
|
+
const interrupt = harness.interrupt
|
|
3325
|
+
if (interrupt) {
|
|
3326
|
+
const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() })
|
|
3327
|
+
if (!result.ok) throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`)
|
|
3328
|
+
}
|
|
3329
|
+
const archived = await archiveSessionUnlocked(id)
|
|
3330
|
+
if (!archived) return false
|
|
3331
|
+
wt = await findWorktree(id)
|
|
3332
|
+
if (!wt) return false
|
|
3333
|
+
}
|
|
2752
3334
|
const target = wt
|
|
2753
3335
|
return target.branch
|
|
2754
|
-
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
|
|
2755
|
-
: closeOwnedSessionUnlocked(id, target)
|
|
3336
|
+
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source))
|
|
3337
|
+
: closeOwnedSessionUnlocked(id, target, source)
|
|
3338
|
+
}
|
|
3339
|
+
export const closeSession = (id: string, rawSource?: unknown): Promise<boolean> => {
|
|
3340
|
+
const source = normalizeCloseSource(rawSource)
|
|
3341
|
+
return withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id, source)))
|
|
2756
3342
|
}
|
|
2757
|
-
export const closeSession = (id: string): Promise<boolean> =>
|
|
2758
|
-
withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
|
|
2759
3343
|
|
|
2760
3344
|
export type CorruptRecordQuarantineWitness = {
|
|
2761
3345
|
adapter: string
|
|
@@ -3096,21 +3680,21 @@ export function statusLegend(color = true): string {
|
|
|
3096
3680
|
return c('90', ' key: ') + parts.join(' ')
|
|
3097
3681
|
}
|
|
3098
3682
|
|
|
3099
|
-
// human-friendly aligned table: header + (glyph + colour + status +
|
|
3683
|
+
// human-friendly aligned table: header + (glyph + colour + status + title + id + merges + note) rows +
|
|
3100
3684
|
// a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
|
|
3101
3685
|
export function formatTable(sessions: Session[], color = true): string {
|
|
3102
3686
|
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
3103
3687
|
if (!sessions.length) return c('90', ' no living sessions')
|
|
3104
|
-
const header = c('90', ` ${'STATUS'.padEnd(13)} ${'
|
|
3688
|
+
const header = c('90', ` ${'STATUS'.padEnd(13)} ${'TITLE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
|
|
3105
3689
|
const rows = sessions.map((s) => {
|
|
3106
3690
|
const g = STATUS_GLYPH[s.status] ?? '\u00b7'
|
|
3107
3691
|
const code = ANSI[s.status] ?? '0'
|
|
3108
|
-
const
|
|
3692
|
+
const title = padWidth(truncWidth(sessionTitle(s), 22), 22)
|
|
3109
3693
|
const st = s.status.padEnd(13)
|
|
3110
3694
|
const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
|
|
3111
3695
|
const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
|
|
3112
3696
|
const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
|
|
3113
|
-
return ` ${c(code, g)} ${c(code, st)} ${
|
|
3697
|
+
return ` ${c(code, g)} ${c(code, st)} ${title} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
|
|
3114
3698
|
})
|
|
3115
3699
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3116
3700
|
}
|
|
@@ -3123,27 +3707,72 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
3123
3707
|
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3124
3708
|
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3125
3709
|
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3126
|
-
|
|
3710
|
+
type DispatchIdempotency = SentDispatchReceipt
|
|
3711
|
+
type DispatchAcceptCode = 'dispatch_key_reused' | 'session_merge_not_proposed' | 'session_merge_branch_unproven' | 'session_merge_head_changed'
|
|
3712
|
+
type AcceptedDispatch = DispatchResult & { replayed?: boolean; code?: DispatchAcceptCode }
|
|
3713
|
+
type SendTextOptions = {
|
|
3714
|
+
replyVia?: 'note'
|
|
3715
|
+
idempotency?: DispatchIdempotency
|
|
3716
|
+
acceptGuard?: (record: SessRec) => Promise<void>
|
|
3717
|
+
deferDrain?: boolean
|
|
3718
|
+
}
|
|
3719
|
+
export async function sendText(id: string, text: string, from?: string, opts: SendTextOptions = {}): Promise<AcceptedDispatch> {
|
|
3127
3720
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3128
|
-
|
|
3129
|
-
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3130
|
-
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3131
|
-
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3132
|
-
// a message that was already accepted.
|
|
3133
|
-
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3721
|
+
let replayed = false
|
|
3134
3722
|
try {
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3723
|
+
// Taking a declared sender's record lock makes close a real outgoing fence even across backend processes:
|
|
3724
|
+
// a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
|
|
3725
|
+
// the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
|
|
3726
|
+
// name an otherwise-unused lock until a matching session is closed.
|
|
3727
|
+
await withRecordLocks([id, ...(from ? [from] : [])].sort(), async () => {
|
|
3728
|
+
if (from && senderDeliveryRevoked(from)) throw new ResourceConflict(`sender session ${from} is closed; prompt NOT delivered`)
|
|
3729
|
+
const rec = readRecord(id)
|
|
3730
|
+
if (!rec) throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`)
|
|
3731
|
+
const accept = async () => {
|
|
3732
|
+
if (opts.idempotency) {
|
|
3733
|
+
const prior = sentDispatchReceipt(id, opts.idempotency.operation, opts.idempotency.requestDigest)
|
|
3734
|
+
if (prior) {
|
|
3735
|
+
if (prior.payloadHash !== opts.idempotency.payloadHash) {
|
|
3736
|
+
const conflict = new ResourceConflict(`idempotency key is already bound to another ${opts.idempotency.operation} payload`)
|
|
3737
|
+
Object.assign(conflict, { code: 'dispatch_key_reused' })
|
|
3738
|
+
throw conflict
|
|
3739
|
+
}
|
|
3740
|
+
if (prior.delivery && !prior.delivered) {
|
|
3741
|
+
ensurePendingWhileLocked(id, keyedPendingMessage(opts.idempotency, prior.mid, prior.delivery))
|
|
3742
|
+
}
|
|
3743
|
+
replayed = true
|
|
3744
|
+
return
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
await opts.acceptGuard?.(rec)
|
|
3748
|
+
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3749
|
+
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3750
|
+
// a message that was already accepted.
|
|
3751
|
+
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3752
|
+
const dispatchReceipt = opts.idempotency
|
|
3753
|
+
? { ...opts.idempotency, delivery: { text: prompt.text, from: from ?? null } }
|
|
3754
|
+
: undefined
|
|
3755
|
+
const appended = appendSent(id, text, from ?? null, prompt.replyVia, dispatchReceipt)
|
|
3756
|
+
enqueue(id, opts.idempotency
|
|
3757
|
+
? keyedPendingMessage(opts.idempotency, appended.mid, dispatchReceipt!.delivery!)
|
|
3758
|
+
: { mid: appended.mid, text: prompt.text, from: from ?? null })
|
|
3759
|
+
}
|
|
3760
|
+
if (opts.idempotency) await withDeliveryLocks([id], accept)
|
|
3761
|
+
else await accept()
|
|
3138
3762
|
})
|
|
3139
3763
|
} catch (error) {
|
|
3140
|
-
|
|
3764
|
+
const code = (error as { code?: DispatchAcceptCode })?.code
|
|
3765
|
+
return {
|
|
3766
|
+
ok: false,
|
|
3767
|
+
error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`,
|
|
3768
|
+
...(code ? { code } : {}),
|
|
3769
|
+
}
|
|
3141
3770
|
}
|
|
3142
3771
|
// Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
|
|
3143
3772
|
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
3144
3773
|
// the text in a live agent's current turn instead of up to one tick later.
|
|
3145
|
-
await drainSession(id)
|
|
3146
|
-
return { ok: true }
|
|
3774
|
+
if (!opts.deferDrain) await drainSession(id)
|
|
3775
|
+
return { ok: true, ...(opts.idempotency ? { replayed } : {}) }
|
|
3147
3776
|
}
|
|
3148
3777
|
|
|
3149
3778
|
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
@@ -3155,6 +3784,12 @@ export async function drainSession(id: string): Promise<void> {
|
|
|
3155
3784
|
if (!rec) return
|
|
3156
3785
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3157
3786
|
await drain(id, async (msg) => {
|
|
3787
|
+
if (msg.dispatch) {
|
|
3788
|
+
const receipt = sentDispatchReceipt(id, msg.dispatch.operation, msg.dispatch.requestDigest)
|
|
3789
|
+
if (!receipt || receipt.mid !== msg.mid || !receipt.delivery
|
|
3790
|
+
|| receipt.delivery.text !== msg.text || receipt.delivery.from !== msg.from) return false
|
|
3791
|
+
if (receipt.delivered) return true
|
|
3792
|
+
}
|
|
3158
3793
|
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3159
3794
|
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
3160
3795
|
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
@@ -3163,7 +3798,9 @@ export async function drainSession(id: string): Promise<void> {
|
|
|
3163
3798
|
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
|
|
3164
3799
|
} catch { /* no pane to consult — let the insert itself decide */ }
|
|
3165
3800
|
}
|
|
3166
|
-
|
|
3801
|
+
const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)
|
|
3802
|
+
if (delivered.ok && msg.dispatch) settleSentDispatch(id, msg.mid)
|
|
3803
|
+
return delivered.ok
|
|
3167
3804
|
})
|
|
3168
3805
|
}
|
|
3169
3806
|
|