spexcode 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -6,16 +6,18 @@ 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
|
|
|
@@ -84,6 +86,40 @@ export type Session = {
|
|
|
84
86
|
archiveHazard?: string | null // explicit legacy/invariant violation; never hidden as a clean archive
|
|
85
87
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
86
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`)
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
|
|
@@ -139,6 +175,9 @@ export const sessionHeadline = sessionTitle
|
|
|
139
175
|
// execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
|
|
140
176
|
// "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
|
|
141
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
|
|
142
181
|
async function tmux(args: string[], timeoutMs?: number): Promise<string> {
|
|
143
182
|
const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
|
|
144
183
|
return stdout
|
|
@@ -172,6 +211,7 @@ export type SessRec = {
|
|
|
172
211
|
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
173
212
|
createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
|
|
174
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
|
|
175
215
|
launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
|
|
176
216
|
}
|
|
177
217
|
type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
|
|
@@ -299,10 +339,11 @@ async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: Ab
|
|
|
299
339
|
const release = await acquireRecordLock(id, 30_000, signal)
|
|
300
340
|
try { return await body() } finally { release() }
|
|
301
341
|
}
|
|
302
|
-
function
|
|
342
|
+
export function withSessionRecordLockSync<T>(id: string, body: () => T): T {
|
|
303
343
|
const release = acquireRecordLockSync(id)
|
|
304
344
|
try { return body() } finally { release() }
|
|
305
345
|
}
|
|
346
|
+
const withRecordLockSync = withSessionRecordLockSync
|
|
306
347
|
function tryRecordLockSync(id: string): (() => void) | null {
|
|
307
348
|
mkdirSync(recordLockRoot(), { recursive: true })
|
|
308
349
|
const path = recordLockPath(id)
|
|
@@ -366,6 +407,7 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
|
366
407
|
launchOwner: launchOwner || null,
|
|
367
408
|
createRequestId: raw.create_request_id || null,
|
|
368
409
|
createPayloadHash: raw.create_payload_hash || null,
|
|
410
|
+
base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
|
|
369
411
|
launchReadinessPending: pendingRaw ? {
|
|
370
412
|
version: 1,
|
|
371
413
|
startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
|
|
@@ -433,6 +475,9 @@ function writeRecord(rec: SessRec): void {
|
|
|
433
475
|
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
434
476
|
create_request_id: rec.createRequestId ?? '',
|
|
435
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 } : {}),
|
|
436
481
|
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
437
482
|
version: 1,
|
|
438
483
|
startedAt: rec.launchReadinessPending.startedAt,
|
|
@@ -567,22 +612,107 @@ export function cancelSessionWatch(watcher: string, targets: string[]): number {
|
|
|
567
612
|
return cancelled
|
|
568
613
|
}
|
|
569
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
|
+
|
|
570
691
|
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
571
692
|
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
|
|
572
693
|
|
|
573
|
-
//
|
|
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.
|
|
574
702
|
export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
|
|
575
703
|
const m = new Map<string, { panePid?: number; title?: string }>()
|
|
576
704
|
for (const line of out.split('\n')) {
|
|
577
705
|
if (!line) continue
|
|
578
|
-
|
|
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)
|
|
579
709
|
const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
|
|
580
710
|
if (!name || m.has(name)) continue // first pane per session wins
|
|
581
711
|
if (t1 < 0) { m.set(name, {}); continue }
|
|
582
|
-
const rest = line.slice(t1 +
|
|
583
|
-
const t2 = rest.indexOf(
|
|
712
|
+
const rest = line.slice(t1 + separator.length)
|
|
713
|
+
const t2 = rest.indexOf(separator)
|
|
584
714
|
const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
|
|
585
|
-
const title = t2 < 0 ? '' : rest.slice(t2 +
|
|
715
|
+
const title = t2 < 0 ? '' : rest.slice(t2 + separator.length)
|
|
586
716
|
m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
|
|
587
717
|
}
|
|
588
718
|
return m
|
|
@@ -613,13 +743,17 @@ export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean
|
|
|
613
743
|
return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
|
|
614
744
|
}
|
|
615
745
|
|
|
616
|
-
async function liveSnapshot(): Promise<LiveSnap> {
|
|
746
|
+
async function liveSnapshot(targetId?: string): Promise<LiveSnap> {
|
|
617
747
|
const windows = new Map<string, PaneProbe>()
|
|
618
748
|
const titles = new Map<string, string>()
|
|
619
749
|
let out: string
|
|
620
750
|
try {
|
|
621
751
|
// ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
|
|
622
|
-
|
|
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)
|
|
623
757
|
} catch (e) {
|
|
624
758
|
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
625
759
|
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
@@ -647,7 +781,12 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
647
781
|
// The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
|
|
648
782
|
// in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
|
|
649
783
|
const ids = [...windows.keys()]
|
|
650
|
-
|
|
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
|
+
}
|
|
651
790
|
const sockets = new Set<string>()
|
|
652
791
|
const unproven = new Set<string>()
|
|
653
792
|
ids.forEach((id, i) => {
|
|
@@ -801,7 +940,7 @@ function corruptSession(id: string, entry: { path: string; error: string }): Ses
|
|
|
801
940
|
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
802
941
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
803
942
|
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
804
|
-
activity: null, sortKey: null, archiveHazard: null,
|
|
943
|
+
activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
|
|
805
944
|
}
|
|
806
945
|
}
|
|
807
946
|
|
|
@@ -814,7 +953,7 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
814
953
|
const pp = prompt ? oneLinePreview(prompt) : null
|
|
815
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 }
|
|
816
955
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
817
|
-
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 }
|
|
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) }
|
|
818
957
|
}
|
|
819
958
|
|
|
820
959
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
@@ -846,6 +985,14 @@ export async function sessionPrompt(id: string): Promise<string | null> {
|
|
|
846
985
|
// Preserve rows through a transient record-read failure; prune after the store entry disappears.
|
|
847
986
|
const lastKnownSession = new Map<string, Session>()
|
|
848
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
|
+
|
|
849
996
|
export async function listSessions(includeArchived = false): Promise<Session[]> {
|
|
850
997
|
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
851
998
|
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
@@ -893,7 +1040,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
|
|
|
893
1040
|
// A forced public liveness comes only from the shared record projection. Do not let live process/thread
|
|
894
1041
|
// evidence punch through it (including archive hazard repair).
|
|
895
1042
|
if (entry.kind === 'ok' && entry.liveness === 'offline') {
|
|
896
|
-
const pending = toSession(rec, 'offline', 'offline')
|
|
1043
|
+
const pending = boardRow(toSession(rec, 'offline', 'offline'))
|
|
897
1044
|
lastKnownSession.set(id, pending)
|
|
898
1045
|
return pending
|
|
899
1046
|
}
|
|
@@ -917,7 +1064,7 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
|
|
|
917
1064
|
const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true)
|
|
918
1065
|
const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec
|
|
919
1066
|
const projectedLv = projected === rec ? liveness(rec, snap) : physical!
|
|
920
|
-
const s = toSession(projected, reconcile(projected, snap), projectedLv, activity)
|
|
1067
|
+
const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity))
|
|
921
1068
|
if (projected !== rec) s.archiveHazard = changedDuringCensus.has(id)
|
|
922
1069
|
? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
|
|
923
1070
|
: hasValidColdProof(rec)
|
|
@@ -956,10 +1103,16 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
|
|
|
956
1103
|
export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
|
|
957
1104
|
export type ApiBaseInfo = { url: string; source: ApiBaseSource }
|
|
958
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
|
+
|
|
959
1112
|
// the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
|
|
960
1113
|
// `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
|
|
961
1114
|
function explicitApiFlag(): string | null {
|
|
962
|
-
const argv =
|
|
1115
|
+
const argv = optionArgv()
|
|
963
1116
|
const ai = argv.indexOf('--api')
|
|
964
1117
|
if (ai >= 0) {
|
|
965
1118
|
const v = argv[ai + 1]
|
|
@@ -1536,7 +1689,7 @@ export class SessionCreateError extends Error {
|
|
|
1536
1689
|
this.name = 'SessionCreateError'
|
|
1537
1690
|
}
|
|
1538
1691
|
}
|
|
1539
|
-
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
|
|
1692
|
+
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal; base?: string | null }
|
|
1540
1693
|
type SessionCreateRequestOptions = {
|
|
1541
1694
|
requestKey?: string
|
|
1542
1695
|
signal?: AbortSignal
|
|
@@ -1585,12 +1738,16 @@ function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): v
|
|
|
1585
1738
|
export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
|
|
1586
1739
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
|
|
1587
1740
|
const input = body as Record<string, unknown>
|
|
1588
|
-
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()
|
|
1589
1742
|
if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
|
|
1590
1743
|
const prompt = typeof input.prompt === 'string' ? input.prompt : ''
|
|
1591
1744
|
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1592
1745
|
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1593
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
|
|
1594
1751
|
let key: string
|
|
1595
1752
|
try { key = normalizeCreateKey(options.requestKey) }
|
|
1596
1753
|
catch (error) {
|
|
@@ -1599,7 +1756,10 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
|
|
|
1599
1756
|
}
|
|
1600
1757
|
const requestDigest = digest(key)
|
|
1601
1758
|
const id = sessionIdForCreateKey(key)
|
|
1602
|
-
|
|
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 } : {}) }))
|
|
1603
1763
|
const controller = new AbortController()
|
|
1604
1764
|
const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
|
|
1605
1765
|
if (options.signal?.aborted) cancel()
|
|
@@ -1609,7 +1769,7 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
|
|
|
1609
1769
|
traceSessionCreate(id, requestDigest, 'request', 'start')
|
|
1610
1770
|
try {
|
|
1611
1771
|
try {
|
|
1612
|
-
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 })
|
|
1613
1773
|
traceSessionCreate(id, requestDigest, 'request', 'finish')
|
|
1614
1774
|
return { status: 201, session }
|
|
1615
1775
|
} catch (error) {
|
|
@@ -1658,15 +1818,16 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
|
|
|
1658
1818
|
return false
|
|
1659
1819
|
} finally { clearTimeout(timer) }
|
|
1660
1820
|
}
|
|
1661
|
-
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> {
|
|
1662
1822
|
const parent = ownSessionId()
|
|
1663
1823
|
const requestKey = randomUUID()
|
|
1824
|
+
const body = { prompt, parent, launcher, ...(name !== undefined ? { name } : {}), ...(base !== undefined ? { base } : {}) }
|
|
1664
1825
|
const target = await apiBaseInfo()
|
|
1665
|
-
const
|
|
1826
|
+
const apiUrl = target.url
|
|
1666
1827
|
const refused = await probeSessionCreateAuthority(target)
|
|
1667
1828
|
if (refused) {
|
|
1668
1829
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1669
|
-
const fallback = await sessionCreateRequest(
|
|
1830
|
+
const fallback = await sessionCreateRequest(body, { requestKey })
|
|
1670
1831
|
if (fallback.status === 201) return fallback.session
|
|
1671
1832
|
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
|
|
1672
1833
|
error.name = 'BackendError'
|
|
@@ -1677,10 +1838,10 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
1677
1838
|
timer.unref?.()
|
|
1678
1839
|
let res: Response
|
|
1679
1840
|
try {
|
|
1680
|
-
res = await fetch(`${
|
|
1841
|
+
res = await fetch(`${apiUrl}/api/sessions`, {
|
|
1681
1842
|
method: 'POST',
|
|
1682
1843
|
headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
|
|
1683
|
-
body: JSON.stringify(
|
|
1844
|
+
body: JSON.stringify(body),
|
|
1684
1845
|
signal: controller.signal,
|
|
1685
1846
|
})
|
|
1686
1847
|
} catch (error) {
|
|
@@ -1944,8 +2105,8 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
|
|
|
1944
2105
|
return null
|
|
1945
2106
|
}
|
|
1946
2107
|
|
|
1947
|
-
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
|
|
1948
|
-
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
|
|
1949
2110
|
let phase: SessionCreatePhase = 'creation-lock'
|
|
1950
2111
|
let shouldDrain = false
|
|
1951
2112
|
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
@@ -1984,6 +2145,16 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1984
2145
|
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1985
2146
|
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1986
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
|
+
}
|
|
1987
2158
|
const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
|
|
1988
2159
|
const path = join(root, '.worktrees', slug)
|
|
1989
2160
|
const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
|
|
@@ -2041,7 +2212,7 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
2041
2212
|
gitMutationStarted = true
|
|
2042
2213
|
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
|
|
2043
2214
|
const added = await withGitAbortSignal(signal, () => gitTry(
|
|
2044
|
-
['-C', root, 'worktree', 'add', '-b', branch, path,
|
|
2215
|
+
['-C', root, 'worktree', 'add', '-b', branch, path, startPoint],
|
|
2045
2216
|
{ extraEnv: DEFER_FOOTPRINT_REFRESH },
|
|
2046
2217
|
))
|
|
2047
2218
|
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
|
|
@@ -2058,10 +2229,11 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
2058
2229
|
|
|
2059
2230
|
let rec: SessRec = {
|
|
2060
2231
|
session: id, governed: true, worktreePath: path, branch,
|
|
2061
|
-
node: ref || null, title, name
|
|
2232
|
+
node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
|
|
2062
2233
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
2063
2234
|
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
2064
2235
|
launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
|
|
2236
|
+
...(base ? { base } : {}),
|
|
2065
2237
|
}
|
|
2066
2238
|
owned.store = true
|
|
2067
2239
|
const dir = storeDir(id)
|
|
@@ -2411,6 +2583,8 @@ export type ReviewGates = {
|
|
|
2411
2583
|
}
|
|
2412
2584
|
export type ReviewPayload = {
|
|
2413
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
|
|
2414
2588
|
label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
|
|
2415
2589
|
ahead: number // commits the node branch is ahead of main
|
|
2416
2590
|
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
@@ -2419,6 +2593,23 @@ export type ReviewPayload = {
|
|
|
2419
2593
|
proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
|
|
2420
2594
|
}
|
|
2421
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
|
+
|
|
2422
2613
|
// @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
|
|
2423
2614
|
// (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
|
|
2424
2615
|
// it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
|
|
@@ -2465,19 +2656,28 @@ async function lintGate(): Promise<ReviewGates['lint']> {
|
|
|
2465
2656
|
export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
2466
2657
|
const wt = await findWorktree(id)
|
|
2467
2658
|
if (!wt) return null
|
|
2659
|
+
if (!wt.rec.governed || !wt.branch) throw new ResourceConflict(`session ${id} has no governed branch to review`)
|
|
2468
2660
|
const base = mainBranch()
|
|
2661
|
+
const { branchHead, baseHead } = await reviewHeadPair(wt.path, wt.branch, base)
|
|
2469
2662
|
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
2470
|
-
gitA(['-C', wt.path, 'rev-list', '--count', `${
|
|
2663
|
+
gitA(['-C', wt.path, 'rev-list', '--count', `${baseHead}..${branchHead}`]),
|
|
2471
2664
|
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
2472
|
-
mergeBaseDiff(wt.path,
|
|
2473
|
-
mergeConflicts(wt.path,
|
|
2665
|
+
mergeBaseDiff(wt.path, baseHead, branchHead),
|
|
2666
|
+
mergeConflicts(wt.path, baseHead, branchHead),
|
|
2474
2667
|
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
2475
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
|
+
}
|
|
2476
2676
|
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
2477
2677
|
// path is genuine work — this is just the total uncommitted count.
|
|
2478
2678
|
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
2479
2679
|
return {
|
|
2480
|
-
id, node: wt.rec.node, branch: wt.branch,
|
|
2680
|
+
id, node: wt.rec.node, branch: wt.branch, branchHead, baseHead,
|
|
2481
2681
|
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
2482
2682
|
ahead: Number(aheadOut.trim()) || 0,
|
|
2483
2683
|
dirtyNonRuntime, diff,
|
|
@@ -2486,32 +2686,204 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
2486
2686
|
}
|
|
2487
2687
|
}
|
|
2488
2688
|
|
|
2489
|
-
|
|
2490
|
-
|
|
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'`
|
|
2491
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` +
|
|
2492
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` +
|
|
2493
|
-
`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` +
|
|
2494
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. ` +
|
|
2495
|
-
`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. ` +
|
|
2496
2740
|
`5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
2497
2741
|
}
|
|
2498
2742
|
|
|
2499
|
-
|
|
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
|
+
}
|
|
2500
2844
|
const wt = await findWorktree(id)
|
|
2501
2845
|
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
2502
|
-
const branch = wt.branch,
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
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()
|
|
2508
2852
|
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
2509
|
-
const r = await sendText(id, mergePrompt(main, branch, reason)
|
|
2510
|
-
|
|
2511
|
-
|
|
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 }
|
|
2512
2884
|
}
|
|
2513
|
-
export const mergeSession = (id: string
|
|
2514
|
-
mergeSessionUnlocked(id)
|
|
2885
|
+
export const mergeSession = (id: string, options: MergeSessionOptions = {}): Promise<MergeSessionResult> =>
|
|
2886
|
+
mergeSessionUnlocked(id, options)
|
|
2515
2887
|
|
|
2516
2888
|
// @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
|
|
2517
2889
|
// process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
|
|
@@ -2525,6 +2897,12 @@ export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?
|
|
|
2525
2897
|
// adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
|
|
2526
2898
|
const AGENT_EXIT_GRACE_MS = 3000
|
|
2527
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 }
|
|
2528
2906
|
async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, leaf: LeafIdentity): Promise<void> {
|
|
2529
2907
|
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2530
2908
|
if (pid !== leaf.pid)
|
|
@@ -2579,28 +2957,48 @@ const leafAlive = (pid: number): boolean => {
|
|
|
2579
2957
|
catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
|
|
2580
2958
|
}
|
|
2581
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
|
+
|
|
2582
2990
|
async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
// record-only/queued runtime; a live leaf without a matching pid/start/argv stays visible.
|
|
2587
|
-
const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'))
|
|
2588
|
-
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') {
|
|
2589
2994
|
if (rec.stopped || rec.status === 'queued') return null
|
|
2590
2995
|
throw new ResourceConflict(`refusing to stop ${id}: no readable session-owned leaf PID`)
|
|
2591
2996
|
}
|
|
2592
|
-
|
|
2593
|
-
if (
|
|
2594
|
-
|
|
2595
|
-
throw new ResourceConflict(`refusing to stop ${id}:
|
|
2596
|
-
}
|
|
2597
|
-
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
2598
|
-
const ownerNeedle = harness.leafOwnerNeedle?.(rec)
|
|
2599
|
-
if (!ownerNeedle)
|
|
2600
|
-
throw new ResourceConflict(`refusing to stop ${id}: no exact harness identity is registered for leaf PID ${pid}`)
|
|
2601
|
-
if (!argv.includes(ownerNeedle) || processStartToken(pid) !== startToken)
|
|
2602
|
-
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} does not prove argv ownership`)
|
|
2603
|
-
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}`)
|
|
2604
3002
|
}
|
|
2605
3003
|
|
|
2606
3004
|
async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = false, coldReceipt?: unknown): Promise<void> {
|
|
@@ -2665,7 +3063,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2665
3063
|
// A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
|
|
2666
3064
|
// adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
|
|
2667
3065
|
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
2668
|
-
const proofSnap = await liveSnapshot()
|
|
3066
|
+
const proofSnap = await liveSnapshot(id)
|
|
2669
3067
|
if (proofSnap.probeFailed) throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`)
|
|
2670
3068
|
const proofLv = h.runtimeOwnership === 'adapter'
|
|
2671
3069
|
? (proofSnap.windows.has(id) ? 'online' : 'offline')
|
|
@@ -2702,7 +3100,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2702
3100
|
if (!wt) return false
|
|
2703
3101
|
}
|
|
2704
3102
|
|
|
2705
|
-
const snap = await liveSnapshot()
|
|
3103
|
+
const snap = await liveSnapshot(id)
|
|
2706
3104
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`)
|
|
2707
3105
|
const lv = h.runtimeOwnership === 'adapter'
|
|
2708
3106
|
? 'offline'
|
|
@@ -2711,6 +3109,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2711
3109
|
throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`)
|
|
2712
3110
|
// The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
|
|
2713
3111
|
// ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
|
|
3112
|
+
assertSessionOwnerSafe(id, h.id)
|
|
2714
3113
|
const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' })
|
|
2715
3114
|
if (preflight && !preflight.ok) throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`)
|
|
2716
3115
|
// Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
|
|
@@ -2725,7 +3124,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
2725
3124
|
coldCommitted = true
|
|
2726
3125
|
const latest = readRecord(id)
|
|
2727
3126
|
if (!latest) throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`)
|
|
2728
|
-
const finalSnap = await liveSnapshot()
|
|
3127
|
+
const finalSnap = await liveSnapshot(id)
|
|
2729
3128
|
if (finalSnap.probeFailed) throw new ResourceConflict(`refusing to archive ${id}: final liveness probe failed; the leaf may still be live`)
|
|
2730
3129
|
const finalLv = h.runtimeOwnership === 'adapter'
|
|
2731
3130
|
? (finalSnap.windows.has(id) ? 'online' : 'offline')
|
|
@@ -2774,14 +3173,16 @@ async function assertColdRetirementSafe(id: string, rec: SessRec): Promise<void>
|
|
|
2774
3173
|
if (rec.adapterRecovery)
|
|
2775
3174
|
throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`)
|
|
2776
3175
|
|
|
2777
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
3176
|
+
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
|
|
2778
3177
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2779
3178
|
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`)
|
|
2780
3179
|
if (socket === 'live') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`)
|
|
2781
3180
|
if (socket === 'unproven') throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`)
|
|
2782
|
-
const
|
|
2783
|
-
if (
|
|
2784
|
-
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`)
|
|
2785
3186
|
|
|
2786
3187
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
2787
3188
|
if (harness.coldRetirementPreflight) {
|
|
@@ -2818,7 +3219,7 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
2818
3219
|
if (rec.adapterRecovery || launching.has(id))
|
|
2819
3220
|
throw new ResourceConflict(`refusing to close queued session ${id}: target launch/recovery is already in progress`)
|
|
2820
3221
|
|
|
2821
|
-
const [snap, socket] = await Promise.all([liveSnapshot(), rendezvousListening(id)])
|
|
3222
|
+
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)])
|
|
2822
3223
|
if (snap.probeFailed) throw new ResourceConflict(`refusing to close queued session ${id}: liveness probe failed; target runtime absence is unproven`)
|
|
2823
3224
|
if (snap.windows.has(id)) throw new ResourceConflict(`refusing to close queued session ${id}: target tmux window already exists`)
|
|
2824
3225
|
if (socket === 'live') throw new ResourceConflict(`refusing to close queued session ${id}: target rendezvous transport already exists`)
|
|
@@ -2850,15 +3251,21 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
2850
3251
|
}
|
|
2851
3252
|
}
|
|
2852
3253
|
|
|
2853
|
-
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> {
|
|
2854
3255
|
const root = mainRoot()
|
|
2855
3256
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
|
|
2856
3257
|
if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
|
|
2857
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.
|
|
2858
3267
|
if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
|
|
2859
|
-
|
|
2860
|
-
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
2861
|
-
else await stopAgentProcess(id, wt.rec)
|
|
3268
|
+
appendCloseLedger(id, wt.rec, source)
|
|
2862
3269
|
let slot: string | null = null
|
|
2863
3270
|
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
2864
3271
|
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
@@ -2883,13 +3290,16 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
|
|
|
2883
3290
|
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
|
|
2884
3291
|
catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
2885
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)
|
|
2886
3296
|
if (closesCodexBinding && wt.rec.harnessSessionId) {
|
|
2887
3297
|
bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
|
|
2888
3298
|
}
|
|
2889
3299
|
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
2890
3300
|
return true
|
|
2891
3301
|
}
|
|
2892
|
-
async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
3302
|
+
async function closeSessionUnlocked(id: string, source: CloseSource): Promise<boolean> {
|
|
2893
3303
|
let wt: { path: string; branch: string | null; rec: SessRec } | null = null
|
|
2894
3304
|
try { wt = await findWorktree(id) }
|
|
2895
3305
|
catch (e) {
|
|
@@ -2906,13 +3316,30 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
|
2906
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.`)
|
|
2907
3317
|
}
|
|
2908
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
|
+
}
|
|
2909
3334
|
const target = wt
|
|
2910
3335
|
return target.branch
|
|
2911
|
-
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
|
|
2912
|
-
: 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)))
|
|
2913
3342
|
}
|
|
2914
|
-
export const closeSession = (id: string): Promise<boolean> =>
|
|
2915
|
-
withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
|
|
2916
3343
|
|
|
2917
3344
|
export type CorruptRecordQuarantineWitness = {
|
|
2918
3345
|
adapter: string
|
|
@@ -3253,21 +3680,21 @@ export function statusLegend(color = true): string {
|
|
|
3253
3680
|
return c('90', ' key: ') + parts.join(' ')
|
|
3254
3681
|
}
|
|
3255
3682
|
|
|
3256
|
-
// human-friendly aligned table: header + (glyph + colour + status +
|
|
3683
|
+
// human-friendly aligned table: header + (glyph + colour + status + title + id + merges + note) rows +
|
|
3257
3684
|
// a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
|
|
3258
3685
|
export function formatTable(sessions: Session[], color = true): string {
|
|
3259
3686
|
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
3260
3687
|
if (!sessions.length) return c('90', ' no living sessions')
|
|
3261
|
-
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`)
|
|
3262
3689
|
const rows = sessions.map((s) => {
|
|
3263
3690
|
const g = STATUS_GLYPH[s.status] ?? '\u00b7'
|
|
3264
3691
|
const code = ANSI[s.status] ?? '0'
|
|
3265
|
-
const
|
|
3692
|
+
const title = padWidth(truncWidth(sessionTitle(s), 22), 22)
|
|
3266
3693
|
const st = s.status.padEnd(13)
|
|
3267
3694
|
const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
|
|
3268
3695
|
const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
|
|
3269
3696
|
const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
|
|
3270
|
-
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}`
|
|
3271
3698
|
})
|
|
3272
3699
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3273
3700
|
}
|
|
@@ -3280,27 +3707,72 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
3280
3707
|
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3281
3708
|
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3282
3709
|
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3283
|
-
|
|
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> {
|
|
3284
3720
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3285
|
-
|
|
3286
|
-
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3287
|
-
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3288
|
-
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3289
|
-
// a message that was already accepted.
|
|
3290
|
-
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3721
|
+
let replayed = false
|
|
3291
3722
|
try {
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
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()
|
|
3295
3762
|
})
|
|
3296
3763
|
} catch (error) {
|
|
3297
|
-
|
|
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
|
+
}
|
|
3298
3770
|
}
|
|
3299
3771
|
// Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
|
|
3300
3772
|
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
3301
3773
|
// the text in a live agent's current turn instead of up to one tick later.
|
|
3302
|
-
await drainSession(id)
|
|
3303
|
-
return { ok: true }
|
|
3774
|
+
if (!opts.deferDrain) await drainSession(id)
|
|
3775
|
+
return { ok: true, ...(opts.idempotency ? { replayed } : {}) }
|
|
3304
3776
|
}
|
|
3305
3777
|
|
|
3306
3778
|
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
@@ -3312,6 +3784,12 @@ export async function drainSession(id: string): Promise<void> {
|
|
|
3312
3784
|
if (!rec) return
|
|
3313
3785
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3314
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
|
+
}
|
|
3315
3793
|
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3316
3794
|
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
3317
3795
|
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
@@ -3320,7 +3798,9 @@ export async function drainSession(id: string): Promise<void> {
|
|
|
3320
3798
|
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
|
|
3321
3799
|
} catch { /* no pane to consult — let the insert itself decide */ }
|
|
3322
3800
|
}
|
|
3323
|
-
|
|
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
|
|
3324
3804
|
})
|
|
3325
3805
|
}
|
|
3326
3806
|
|