spexcode 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +18 -1
- package/spec-cli/src/client.ts +21 -0
- package/spec-cli/src/git.ts +460 -238
- package/spec-cli/src/harness.ts +116 -24
- package/spec-cli/src/help.ts +3 -1
- package/spec-cli/src/index.ts +48 -9
- package/spec-cli/src/layout.ts +2 -0
- package/spec-cli/src/mentions.ts +10 -5
- package/spec-cli/src/process-identity.ts +20 -0
- package/spec-cli/src/reviewSnapshot.ts +4 -0
- package/spec-cli/src/reviews.ts +10 -5
- package/spec-cli/src/session-maintenance.ts +2 -1
- package/spec-cli/src/sessions.ts +898 -140
- package/spec-cli/src/specs.ts +3 -3
- package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-u2P7KdSg.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-B8wp5_61.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-Bq1Tkb8y.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-B1GxRZXK.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-bAkq9IIT.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-px_rUZVJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-8uGqYM12.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-bpAbfnmS.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-BpL3N3kw.js} +11 -11
- package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-Ckmb1Ez2.js} +1 -1
- package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-CQFbQEMH.js} +1 -1
- package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
- package/spec-dashboard/dist/assets/index-Di1ch5dd.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +0 -41
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
|
-
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
4
5
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs'
|
|
5
6
|
import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
7
8
|
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
8
|
-
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
9
|
-
import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
|
|
10
|
-
import { adapterLoadedReferenceState, defaultHarness, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
9
|
+
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, type ReviewDiffFile } from './git.js'
|
|
10
|
+
import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
|
|
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'
|
|
11
12
|
import { materialize } from './materialize.js'
|
|
12
13
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
13
14
|
import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
@@ -218,7 +219,11 @@ export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-
|
|
|
218
219
|
|
|
219
220
|
// worktrees + branches are created off MAIN even when the server runs inside a worktree.
|
|
220
221
|
function mainRoot(): string {
|
|
221
|
-
try {
|
|
222
|
+
try {
|
|
223
|
+
const checkout = dirname(gitCommonDir())
|
|
224
|
+
const configured = readConfig(checkout).main?.trim()
|
|
225
|
+
return configured ? resolve(checkout, configured) : checkout
|
|
226
|
+
}
|
|
222
227
|
catch { return repoRoot() }
|
|
223
228
|
}
|
|
224
229
|
|
|
@@ -249,6 +254,8 @@ export type SessRec = {
|
|
|
249
254
|
launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
|
|
250
255
|
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
251
256
|
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
257
|
+
createRequestId?: string | null // digest of the public Idempotency-Key; binds retry without storing the bearer
|
|
258
|
+
createPayloadHash?: string | null // exact normalized create payload bound to createRequestId
|
|
252
259
|
launchReadinessPending?: LaunchReadinessPending | null // internal resume candidate; every public reader projects `original` until one final publish
|
|
253
260
|
}
|
|
254
261
|
type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 'stopped' | 'archived' | 'coldProof' | 'adapterRecovery'>
|
|
@@ -353,10 +360,24 @@ function acquireRecordLockSync(id: string, timeoutMs = 30_000): () => void {
|
|
|
353
360
|
}
|
|
354
361
|
}
|
|
355
362
|
}
|
|
356
|
-
|
|
363
|
+
const abortedOperation = (signal: AbortSignal): Error => signal.reason instanceof Error
|
|
364
|
+
? signal.reason
|
|
365
|
+
: Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
|
|
366
|
+
async function recordLockPause(signal?: AbortSignal): Promise<void> {
|
|
367
|
+
if (!signal) { await new Promise((resolve) => setTimeout(resolve, 10)); return }
|
|
368
|
+
if (signal.aborted) throw abortedOperation(signal)
|
|
369
|
+
await new Promise<void>((resolve, reject) => {
|
|
370
|
+
const timer = setTimeout(done, 10)
|
|
371
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(abortedOperation(signal)) }
|
|
372
|
+
function done() { signal!.removeEventListener('abort', abort); resolve() }
|
|
373
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
async function acquireRecordLock(id: string, timeoutMs = 30_000, signal?: AbortSignal): Promise<() => void> {
|
|
357
377
|
mkdirSync(recordLockRoot(), { recursive: true })
|
|
358
378
|
const path = recordLockPath(id), deadline = Date.now() + timeoutMs
|
|
359
379
|
for (;;) {
|
|
380
|
+
if (signal?.aborted) throw abortedOperation(signal)
|
|
360
381
|
try {
|
|
361
382
|
const fd = openSync(path, 'wx')
|
|
362
383
|
writeSync(fd, String(process.pid))
|
|
@@ -370,12 +391,12 @@ async function acquireRecordLock(id: string, timeoutMs = 30_000): Promise<() =>
|
|
|
370
391
|
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
371
392
|
}
|
|
372
393
|
if (Date.now() >= deadline) throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`)
|
|
373
|
-
await
|
|
394
|
+
await recordLockPause(signal)
|
|
374
395
|
}
|
|
375
396
|
}
|
|
376
397
|
}
|
|
377
|
-
async function withRecordLock<T>(id: string, body: () => Promise<T
|
|
378
|
-
const release = await acquireRecordLock(id)
|
|
398
|
+
async function withRecordLock<T>(id: string, body: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
399
|
+
const release = await acquireRecordLock(id, 30_000, signal)
|
|
379
400
|
try { return await body() } finally { release() }
|
|
380
401
|
}
|
|
381
402
|
function withRecordLockSync<T>(id: string, body: () => T): T {
|
|
@@ -443,6 +464,8 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
|
443
464
|
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
444
465
|
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
445
466
|
launchOwner: launchOwner || null,
|
|
467
|
+
createRequestId: raw.create_request_id || null,
|
|
468
|
+
createPayloadHash: raw.create_payload_hash || null,
|
|
446
469
|
launchReadinessPending: pendingRaw ? {
|
|
447
470
|
version: 1,
|
|
448
471
|
startedAt: (raw.launch_readiness_pending as { startedAt: number }).startedAt,
|
|
@@ -531,6 +554,8 @@ function writeRecord(rec: SessRec): void {
|
|
|
531
554
|
launcher: rec.launcher ?? '',
|
|
532
555
|
launch_cmd: rec.launchCmd ?? '',
|
|
533
556
|
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
557
|
+
create_request_id: rec.createRequestId ?? '',
|
|
558
|
+
create_payload_hash: rec.createPayloadHash ?? '',
|
|
534
559
|
launch_readiness_pending: rec.launchReadinessPending ? {
|
|
535
560
|
version: 1,
|
|
536
561
|
startedAt: rec.launchReadinessPending.startedAt,
|
|
@@ -1275,7 +1300,7 @@ export const isBackendUnreachable = (e: unknown): boolean =>
|
|
|
1275
1300
|
// take unicode), so a CJK prompt survives as the readable name its author typed instead of being stripped to
|
|
1276
1301
|
// nothing — transliteration would buy ASCII at the cost of a dependency and a name nobody wrote. NFC pins one
|
|
1277
1302
|
// canonical byte form across IME/OS variants. Non-empty is guaranteed by the 'session' fallback; uniqueness
|
|
1278
|
-
// is the caller's job (
|
|
1303
|
+
// is the caller's job (the create transaction suffixes the session short-id).
|
|
1279
1304
|
export const slugify = (s: string | null) =>
|
|
1280
1305
|
(s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
|
|
1281
1306
|
|
|
@@ -1322,7 +1347,7 @@ export function composeCommandPrompt(raw: string, presets: CommandPreset[], spec
|
|
|
1322
1347
|
return free ? `${body}\n\n${free}` : body
|
|
1323
1348
|
}
|
|
1324
1349
|
|
|
1325
|
-
// Load only the one live preset named by the raw invocation. Both
|
|
1350
|
+
// Load only the one live preset named by the raw invocation. Both session creation and sendText call this seam, so
|
|
1326
1351
|
// launch and an existing session's inbox resolve identical plugin data with identical target semantics.
|
|
1327
1352
|
export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
|
|
1328
1353
|
const commandName = raw.match(/^\/(\S+)/)?.[1]
|
|
@@ -1597,7 +1622,7 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
|
|
|
1597
1622
|
const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
|
|
1598
1623
|
|
|
1599
1624
|
// @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
|
|
1600
|
-
// re-entrancy-guarded; safe to call on every slot-freeing event (
|
|
1625
|
+
// re-entrancy-guarded; safe to call on every slot-freeing event (session creation / close / propose) AND on a
|
|
1601
1626
|
// periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
|
|
1602
1627
|
// (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
|
|
1603
1628
|
// so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
|
|
@@ -1636,7 +1661,7 @@ const requestQueueDrain = (): void => {
|
|
|
1636
1661
|
}
|
|
1637
1662
|
|
|
1638
1663
|
// @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
|
|
1639
|
-
//
|
|
1664
|
+
// session creation/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
|
|
1640
1665
|
// going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
|
|
1641
1666
|
// socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
|
|
1642
1667
|
// and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
|
|
@@ -1762,16 +1787,13 @@ export function superviseTurnFailures(intervalMs = 1000): void {
|
|
|
1762
1787
|
// cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
|
|
1763
1788
|
// mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
|
|
1764
1789
|
// (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
|
|
1765
|
-
|
|
1766
|
-
|
|
1790
|
+
type BackendSettings = { layout?: { main?: string } }
|
|
1791
|
+
function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
|
|
1792
|
+
const { url, source } = target
|
|
1767
1793
|
if (source === 'flag') return // explicitly routed — the caller named the target
|
|
1768
1794
|
let localMain: string
|
|
1769
1795
|
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
1770
|
-
|
|
1771
|
-
try {
|
|
1772
|
-
const r = await fetch(`${url}/api/settings`)
|
|
1773
|
-
if (r.ok) served = (await r.json() as { layout?: { main?: string } }).layout?.main ?? null
|
|
1774
|
-
} catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
|
|
1796
|
+
const served = settings?.layout?.main ?? null
|
|
1775
1797
|
if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
|
|
1776
1798
|
let backendMain: string
|
|
1777
1799
|
try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
|
|
@@ -1784,15 +1806,84 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1784
1806
|
throw e
|
|
1785
1807
|
}
|
|
1786
1808
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1809
|
+
export async function assertProjectMatch(verb: string): Promise<void> {
|
|
1810
|
+
const target = await apiBaseInfo()
|
|
1811
|
+
if (target.source === 'flag') return
|
|
1812
|
+
let settings: BackendSettings | null = null
|
|
1813
|
+
try {
|
|
1814
|
+
const r = await fetch(`${target.url}/api/settings`)
|
|
1815
|
+
if (r.ok) settings = await r.json() as BackendSettings
|
|
1816
|
+
} catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
|
|
1817
|
+
assertProjectSettingsMatch(verb, target, settings)
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
export type SessionCreateFailureCode =
|
|
1821
|
+
| 'session_create_timeout'
|
|
1822
|
+
| 'session_create_cancelled'
|
|
1823
|
+
| 'session_create_failed'
|
|
1824
|
+
| 'session_create_cleanup_failed'
|
|
1825
|
+
| 'session_create_key_reused'
|
|
1826
|
+
type SessionCreateFailureStatus = 400 | 408 | 409 | 500 | 504
|
|
1827
|
+
type SessionCreatePhase = 'request' | 'creation-lock' | 'launcher-resolution' | 'target-resolution' | 'git-worktree' | 'materialize' | 'record-write' | 'launcher-queue' | 'cleanup'
|
|
1828
|
+
export class SessionCreateError extends Error {
|
|
1829
|
+
constructor(
|
|
1830
|
+
readonly code: SessionCreateFailureCode,
|
|
1831
|
+
readonly phase: SessionCreatePhase,
|
|
1832
|
+
message: string,
|
|
1833
|
+
readonly status: SessionCreateFailureStatus,
|
|
1834
|
+
) {
|
|
1835
|
+
super(message)
|
|
1836
|
+
this.name = 'SessionCreateError'
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
type SessionCreateContext = { id: string; requestDigest: string; payloadHash: string; signal: AbortSignal }
|
|
1840
|
+
type SessionCreateRequestOptions = {
|
|
1841
|
+
requestKey?: string
|
|
1842
|
+
signal?: AbortSignal
|
|
1843
|
+
timeoutMs?: number
|
|
1844
|
+
operation?: 'create' | 'fallback-create'
|
|
1845
|
+
}
|
|
1789
1846
|
export type SessionCreateRequestResult =
|
|
1790
1847
|
| { status: 201; session: Session }
|
|
1791
|
-
| { status:
|
|
1848
|
+
| { status: SessionCreateFailureStatus; error: string; code?: SessionCreateFailureCode; phase?: SessionCreatePhase }
|
|
1849
|
+
|
|
1850
|
+
const DEFAULT_CREATE_TIMEOUT_MS = 30_000
|
|
1851
|
+
export function sessionCreateTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
1852
|
+
const configured = Number(env.SPEXCODE_SESSION_CREATE_TIMEOUT_MS)
|
|
1853
|
+
return Number.isFinite(configured) ? Math.max(250, Math.min(120_000, Math.floor(configured))) : DEFAULT_CREATE_TIMEOUT_MS
|
|
1854
|
+
}
|
|
1855
|
+
function normalizeCreateKey(raw: string | undefined): string {
|
|
1856
|
+
const key = raw?.trim() || randomUUID()
|
|
1857
|
+
if (key.length > 128 || !/^[\x21-\x7e]+$/.test(key)) {
|
|
1858
|
+
throw new SessionCreateError('session_create_failed', 'request', 'Idempotency-Key must be 1-128 visible ASCII characters', 400)
|
|
1859
|
+
}
|
|
1860
|
+
return key
|
|
1861
|
+
}
|
|
1862
|
+
const digest = (value: string): string => createHash('sha256').update(value).digest('hex')
|
|
1863
|
+
export function sessionIdForCreateKey(key: string): string {
|
|
1864
|
+
const hex = digest(`spexcode-session-create\0${key}`)
|
|
1865
|
+
const uuid = `${hex.slice(0, 12)}4${hex.slice(13, 16)}${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17)}`
|
|
1866
|
+
return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20, 32)}`
|
|
1867
|
+
}
|
|
1868
|
+
function traceSessionCreate(id: string, requestDigest: string, phase: SessionCreatePhase, event: 'start' | 'finish' | 'abort' | 'publish', detail?: string): void {
|
|
1869
|
+
console.error(`spex session-create ${JSON.stringify({ ts: new Date().toISOString(), request: requestDigest.slice(0, 12), session: id, phase, event, ...(detail ? { detail } : {}) })}`)
|
|
1870
|
+
}
|
|
1871
|
+
function createAbortError(signal: AbortSignal, phase: SessionCreatePhase): SessionCreateError {
|
|
1872
|
+
const timedOut = signal.reason instanceof SessionCreateError && signal.reason.code === 'session_create_timeout'
|
|
1873
|
+
return new SessionCreateError(
|
|
1874
|
+
timedOut ? 'session_create_timeout' : 'session_create_cancelled',
|
|
1875
|
+
phase,
|
|
1876
|
+
timedOut ? `session creation timed out during ${phase}` : `session creation was cancelled during ${phase}`,
|
|
1877
|
+
timedOut ? 504 : 408,
|
|
1878
|
+
)
|
|
1879
|
+
}
|
|
1880
|
+
function throwIfCreateAborted(signal: AbortSignal, phase: SessionCreatePhase): void {
|
|
1881
|
+
if (signal.aborted) throw createAbortError(signal, phase)
|
|
1882
|
+
}
|
|
1792
1883
|
|
|
1793
1884
|
// The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
|
|
1794
1885
|
// contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
|
|
1795
|
-
export async function sessionCreateRequest(body: unknown,
|
|
1886
|
+
export async function sessionCreateRequest(body: unknown, options: SessionCreateRequestOptions = {}): Promise<SessionCreateRequestResult> {
|
|
1796
1887
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
|
|
1797
1888
|
const input = body as Record<string, unknown>
|
|
1798
1889
|
const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
|
|
@@ -1801,14 +1892,42 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
|
|
|
1801
1892
|
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1802
1893
|
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1803
1894
|
const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1895
|
+
let key: string
|
|
1896
|
+
try { key = normalizeCreateKey(options.requestKey) }
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
const failure = error as SessionCreateError
|
|
1899
|
+
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1900
|
+
}
|
|
1901
|
+
const requestDigest = digest(key)
|
|
1902
|
+
const id = sessionIdForCreateKey(key)
|
|
1903
|
+
const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null }))
|
|
1904
|
+
const controller = new AbortController()
|
|
1905
|
+
const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408))
|
|
1906
|
+
if (options.signal?.aborted) cancel()
|
|
1907
|
+
else options.signal?.addEventListener('abort', cancel, { once: true })
|
|
1908
|
+
const timer = setTimeout(() => controller.abort(new SessionCreateError('session_create_timeout', 'request', 'session creation exceeded its deadline', 504)), options.timeoutMs ?? sessionCreateTimeoutMs())
|
|
1909
|
+
timer.unref?.()
|
|
1910
|
+
traceSessionCreate(id, requestDigest, 'request', 'start')
|
|
1911
|
+
try {
|
|
1912
|
+
return await runSessionOperation({ op: options.operation ?? 'create' }, async () => {
|
|
1913
|
+
try {
|
|
1914
|
+
const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
|
|
1915
|
+
traceSessionCreate(id, requestDigest, 'request', 'finish')
|
|
1916
|
+
return { status: 201, session }
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
if (error instanceof SessionMaintenanceError) throw error
|
|
1919
|
+
const failure = error instanceof SessionCreateError
|
|
1920
|
+
? error
|
|
1921
|
+
: controller.signal.aborted
|
|
1922
|
+
? createAbortError(controller.signal, 'request')
|
|
1923
|
+
: new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
|
|
1924
|
+
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1925
|
+
}
|
|
1926
|
+
})
|
|
1927
|
+
} finally {
|
|
1928
|
+
clearTimeout(timer)
|
|
1929
|
+
options.signal?.removeEventListener('abort', cancel)
|
|
1930
|
+
}
|
|
1812
1931
|
}
|
|
1813
1932
|
|
|
1814
1933
|
// @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
|
|
@@ -1816,30 +1935,76 @@ export async function sessionCreateRequest(body: unknown, create: SessionCreateF
|
|
|
1816
1935
|
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
|
|
1817
1936
|
// bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
|
|
1818
1937
|
// comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
|
|
1819
|
-
// either process.) So the CLI POSTs to the running backend whenever one answers. Only
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1938
|
+
// either process.) So the CLI POSTs to the running backend whenever one answers. Only an explicit
|
|
1939
|
+
// ECONNREFUSED proves there is no owner on the target and permits the legacy in-process fallback. A timeout,
|
|
1940
|
+
// reset, DNS failure, or other ambiguous transport result may hide an admitted request, so it fails loud and
|
|
1941
|
+
// never starts a second writer. The backend POST, mention dispatch, and fallback all enter through
|
|
1942
|
+
// sessionCreateRequest; the private preparation half cannot be called without its bounded context.
|
|
1943
|
+
function isExplicitConnectionRefused(error: unknown): boolean {
|
|
1944
|
+
if (!error || typeof error !== 'object') return false
|
|
1945
|
+
if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
|
|
1946
|
+
const errors = (error as { errors?: unknown }).errors
|
|
1947
|
+
if (Array.isArray(errors)) return errors.length > 0 && errors.every(isExplicitConnectionRefused)
|
|
1948
|
+
return isExplicitConnectionRefused((error as { cause?: unknown }).cause)
|
|
1949
|
+
}
|
|
1950
|
+
async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean> {
|
|
1951
|
+
const controller = new AbortController()
|
|
1952
|
+
const timer = setTimeout(() => controller.abort(), 1500)
|
|
1953
|
+
timer.unref?.()
|
|
1954
|
+
try {
|
|
1955
|
+
const response = await fetch(`${target.url}/api/settings`, { signal: controller.signal })
|
|
1956
|
+
let settings: BackendSettings | null = null
|
|
1957
|
+
if (response.ok) {
|
|
1958
|
+
try { settings = await response.json() as BackendSettings }
|
|
1959
|
+
catch (error) { if (controller.signal.aborted) throw error }
|
|
1960
|
+
}
|
|
1961
|
+
assertProjectSettingsMatch('spex session new', target, settings)
|
|
1962
|
+
return false
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
if (isExplicitConnectionRefused(error)) return true
|
|
1965
|
+
const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
|
|
1966
|
+
failed.name = 'BackendError'
|
|
1967
|
+
Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
|
|
1968
|
+
throw failed
|
|
1969
|
+
} finally { clearTimeout(timer) }
|
|
1970
|
+
}
|
|
1822
1971
|
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1823
1972
|
if (maintenanceBrokerDescriptors()) {
|
|
1824
1973
|
throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
|
|
1825
1974
|
}
|
|
1826
|
-
await assertProjectMatch('spex session new')
|
|
1827
1975
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
1828
1976
|
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
1829
1977
|
// backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
|
|
1830
1978
|
// a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
|
|
1831
1979
|
const parent = ownSessionId()
|
|
1980
|
+
const requestKey = randomUUID()
|
|
1981
|
+
const target = await apiBaseInfo()
|
|
1982
|
+
const base = target.url
|
|
1983
|
+
const refused = await probeSessionCreateAuthority(target)
|
|
1984
|
+
if (refused) {
|
|
1985
|
+
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1986
|
+
const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey, operation: 'fallback-create' })
|
|
1987
|
+
if (fallback.status === 201) return fallback.session
|
|
1988
|
+
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
|
|
1989
|
+
error.name = 'BackendError'
|
|
1990
|
+
throw error
|
|
1991
|
+
}
|
|
1992
|
+
const controller = new AbortController()
|
|
1993
|
+
const timer = setTimeout(() => controller.abort(new Error('backend session-create request timed out')), sessionCreateTimeoutMs() + 5_000)
|
|
1994
|
+
timer.unref?.()
|
|
1832
1995
|
let res: Response
|
|
1833
1996
|
try {
|
|
1834
|
-
res = await fetch(`${
|
|
1997
|
+
res = await fetch(`${base}/api/sessions`, {
|
|
1835
1998
|
method: 'POST',
|
|
1836
|
-
headers: { 'content-type': 'application/json' },
|
|
1999
|
+
headers: { 'content-type': 'application/json', 'Idempotency-Key': requestKey },
|
|
1837
2000
|
body: JSON.stringify({ prompt, parent, launcher }),
|
|
2001
|
+
signal: controller.signal,
|
|
1838
2002
|
})
|
|
1839
|
-
} catch {
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
const failed = new Error(`backend session create failed without fallback after admission began: ${error instanceof Error ? error.message : error}`)
|
|
2005
|
+
failed.name = 'BackendError'
|
|
2006
|
+
throw failed
|
|
2007
|
+
} finally { clearTimeout(timer) }
|
|
1843
2008
|
if (!res.ok) {
|
|
1844
2009
|
const text = await res.text().catch(() => '')
|
|
1845
2010
|
let msg = text
|
|
@@ -1868,80 +2033,416 @@ export function spawnerClause(p: SessRec | null): string {
|
|
|
1868
2033
|
`read it there directly. Read only: never write into another session's worktree.`
|
|
1869
2034
|
}
|
|
1870
2035
|
|
|
1871
|
-
|
|
2036
|
+
function sessionCreateFailureRecord(rec: SessRec, error: unknown): SessRec {
|
|
2037
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
2038
|
+
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
|
|
2039
|
+
return { ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` }
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// A materialize failure can leave a tracked contract or .gitignore half-written. Until publication this is
|
|
2043
|
+
// still creation-owned preparation: no worker can have authored work here, so restore HEAD and remove its
|
|
2044
|
+
// untracked artifacts rather than publish a queued record that close must preserve as possibly-user-dirty.
|
|
2045
|
+
// Disable checkout hooks: recovery is not another anchor that may recreate the failed materialization.
|
|
2046
|
+
async function resetFailedMaterializeCandidate(rec: SessRec, signal: AbortSignal): Promise<void> {
|
|
2047
|
+
throwIfCreateAborted(signal, 'materialize')
|
|
2048
|
+
const reset = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'reset', '--hard', '--quiet', 'HEAD'])
|
|
2049
|
+
if (!reset.ok) {
|
|
2050
|
+
const detail = (reset.stderr || reset.stdout || 'git reset failed without diagnostic').trim()
|
|
2051
|
+
throw new SessionCreateError('session_create_failed', 'materialize',
|
|
2052
|
+
`materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
|
|
2053
|
+
}
|
|
2054
|
+
const clean = await gitTry(['-C', rec.worktreePath, '-c', 'core.hooksPath=/dev/null', 'clean', '-fd', '-e', 'spexcode.local.json'])
|
|
2055
|
+
if (clean.ok) return
|
|
2056
|
+
const detail = (clean.stderr || clean.stdout || 'git clean failed without diagnostic').trim()
|
|
2057
|
+
throw new SessionCreateError('session_create_failed', 'materialize',
|
|
2058
|
+
`materialize failed and its prepared worktree could not be restored: ${detail}`, 500)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async function materializeSessionCandidate(rec: SessRec, signal: AbortSignal): Promise<SessRec> {
|
|
2062
|
+
throwIfCreateAborted(signal, 'materialize')
|
|
2063
|
+
try {
|
|
2064
|
+
const req = createRequire(join(pkgRoot(), 'package.json'))
|
|
2065
|
+
const tsxImport = req.resolve('tsx/esm')
|
|
2066
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
2067
|
+
const child = spawn(process.execPath, ['--import', tsxImport, join(pkgRoot(), 'src', 'cli.ts'), 'materialize'], {
|
|
2068
|
+
cwd: rec.worktreePath,
|
|
2069
|
+
env: process.env,
|
|
2070
|
+
detached: true,
|
|
2071
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2072
|
+
})
|
|
2073
|
+
let stderr = '', settled = false
|
|
2074
|
+
const killTree = () => {
|
|
2075
|
+
if (!child.pid) return
|
|
2076
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch { /* process group already gone */ }
|
|
2077
|
+
try { child.kill('SIGKILL') } catch { /* child already gone */ }
|
|
2078
|
+
}
|
|
2079
|
+
const abort = () => killTree()
|
|
2080
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
2081
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk })
|
|
2082
|
+
child.once('error', (error) => {
|
|
2083
|
+
if (settled) return
|
|
2084
|
+
settled = true
|
|
2085
|
+
signal.removeEventListener('abort', abort)
|
|
2086
|
+
reject(error)
|
|
2087
|
+
})
|
|
2088
|
+
child.once('close', (code, childSignal) => {
|
|
2089
|
+
if (settled) return
|
|
2090
|
+
settled = true
|
|
2091
|
+
signal.removeEventListener('abort', abort)
|
|
2092
|
+
if (signal.aborted) { reject(createAbortError(signal, 'materialize')); return }
|
|
2093
|
+
if (code === 0) { resolvePromise(); return }
|
|
2094
|
+
reject(new Error(`materialize exited ${childSignal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`))
|
|
2095
|
+
})
|
|
2096
|
+
if (signal.aborted) abort()
|
|
2097
|
+
})
|
|
2098
|
+
return rec
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
if (signal.aborted || error instanceof SessionCreateError) throw createAbortError(signal, 'materialize')
|
|
2101
|
+
const failed = sessionCreateFailureRecord(rec, error)
|
|
2102
|
+
await resetFailedMaterializeCandidate(rec, signal)
|
|
2103
|
+
return failed
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
type SessionCandidateOwnership = { store: boolean; path: boolean; worktree: boolean; branch: boolean }
|
|
2108
|
+
type SessionCandidateState = { path: boolean; worktree: boolean; branch: boolean }
|
|
2109
|
+
type SessionCandidateStage = 'prepared' | 'git-created' | 'store-created'
|
|
2110
|
+
type SessionCandidateReceipt = {
|
|
2111
|
+
version: 1
|
|
2112
|
+
requestDigest: string
|
|
2113
|
+
payloadHash: string
|
|
2114
|
+
root: string
|
|
2115
|
+
path: string
|
|
2116
|
+
branch: string
|
|
2117
|
+
prestate: { store: false; path: false; worktree: false; branch: false }
|
|
2118
|
+
stage: SessionCandidateStage
|
|
2119
|
+
}
|
|
2120
|
+
type SessionCandidateReceiptRead =
|
|
2121
|
+
| { kind: 'absent' }
|
|
2122
|
+
| { kind: 'invalid'; error: string }
|
|
2123
|
+
| { kind: 'valid'; receipt: SessionCandidateReceipt }
|
|
2124
|
+
|
|
2125
|
+
const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
|
|
2126
|
+
const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
|
|
2127
|
+
const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
|
|
2128
|
+
function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
|
|
2129
|
+
const path = sessionCandidateReceiptPath(id)
|
|
2130
|
+
if (!existsSync(path)) return { kind: 'absent' }
|
|
2131
|
+
try {
|
|
2132
|
+
const value = JSON.parse(readFileSync(path, 'utf8')) as Partial<SessionCandidateReceipt>
|
|
2133
|
+
const prestate = value.prestate
|
|
2134
|
+
if (value.version !== 1 || typeof value.requestDigest !== 'string' || typeof value.payloadHash !== 'string'
|
|
2135
|
+
|| typeof value.root !== 'string' || typeof value.path !== 'string' || typeof value.branch !== 'string'
|
|
2136
|
+
|| !prestate || prestate.store !== false || prestate.path !== false || prestate.worktree !== false || prestate.branch !== false
|
|
2137
|
+
|| !['prepared', 'git-created', 'store-created'].includes(value.stage as string)) {
|
|
2138
|
+
return { kind: 'invalid', error: `invalid private candidate receipt at ${path}` }
|
|
2139
|
+
}
|
|
2140
|
+
return { kind: 'valid', receipt: value as SessionCandidateReceipt }
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
return { kind: 'invalid', error: `invalid private candidate receipt at ${path}: ${error instanceof Error ? error.message : error}` }
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
function writeSessionCandidateReceipt(id: string, receipt: SessionCandidateReceipt): void {
|
|
2146
|
+
const dir = sessionCandidateReceiptDir()
|
|
2147
|
+
mkdirSync(dir, { recursive: true })
|
|
2148
|
+
const path = sessionCandidateReceiptPath(id)
|
|
2149
|
+
const tmp = join(dir, `.${id}.${process.pid}.${randomUUID()}.tmp`)
|
|
2150
|
+
try {
|
|
2151
|
+
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 })
|
|
2152
|
+
renameSync(tmp, path)
|
|
2153
|
+
} finally { rmSync(tmp, { force: true }) }
|
|
2154
|
+
}
|
|
2155
|
+
function retireSessionCandidateReceipt(id: string): boolean {
|
|
2156
|
+
try { rmSync(sessionCandidateReceiptPath(id), { force: true }) } catch { return false }
|
|
2157
|
+
return !existsSync(sessionCandidateReceiptPath(id))
|
|
2158
|
+
}
|
|
2159
|
+
function sessionCandidateReceiptMatches(receipt: SessionCandidateReceipt, context: SessionCreateContext, root: string, path: string, branch: string): boolean {
|
|
2160
|
+
return receipt.requestDigest === context.requestDigest && receipt.payloadHash === context.payloadHash
|
|
2161
|
+
&& receipt.root === root && receipt.path === path && receipt.branch === branch
|
|
2162
|
+
}
|
|
2163
|
+
function publishedSessionCandidateReceiptRetirementFailure(rec: SessRec, root: string): string | null {
|
|
2164
|
+
const durable = readSessionCandidateReceipt(rec.session)
|
|
2165
|
+
if (durable.kind === 'absent') return null
|
|
2166
|
+
if (durable.kind === 'invalid') return durable.error
|
|
2167
|
+
if (!rec.createRequestId || !rec.createPayloadHash || !rec.branch || !rec.worktreePath
|
|
2168
|
+
|| durable.receipt.requestDigest !== rec.createRequestId || durable.receipt.payloadHash !== rec.createPayloadHash
|
|
2169
|
+
|| durable.receipt.root !== root || durable.receipt.path !== rec.worktreePath || durable.receipt.branch !== rec.branch) {
|
|
2170
|
+
return `private candidate receipt at ${sessionCandidateReceiptPath(rec.session)} does not match the published record`
|
|
2171
|
+
}
|
|
2172
|
+
if (!retireSessionCandidateReceipt(rec.session)) return `private candidate receipt remains at ${sessionCandidateReceiptPath(rec.session)}`
|
|
2173
|
+
return readSessionCandidateReceipt(rec.session).kind === 'absent'
|
|
2174
|
+
? null
|
|
2175
|
+
: `private candidate receipt retirement is unproven at ${sessionCandidateReceiptPath(rec.session)}`
|
|
2176
|
+
}
|
|
2177
|
+
async function retirePublishedSessionCandidateReceipt(rec: SessRec, context: SessionCreateContext): Promise<void> {
|
|
2178
|
+
if (!rec.branch || !rec.worktreePath) return
|
|
2179
|
+
if (readSessionCandidateReceipt(rec.session).kind === 'absent') return
|
|
2180
|
+
const root = mainRoot(), path = rec.worktreePath, branch = rec.branch
|
|
2181
|
+
await withRecordLock(sessionCandidateLockId(path, branch), async () => {
|
|
2182
|
+
const failure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
|
|
2183
|
+
if (failure) console.error(`spex: published session ${rec.session.slice(0, 8)} remains the fence for its candidate receipt: ${failure}`)
|
|
2184
|
+
}, context.signal)
|
|
2185
|
+
}
|
|
2186
|
+
async function sessionCandidateState(root: string, path: string, branch: string, signal: AbortSignal): Promise<SessionCandidateState> {
|
|
2187
|
+
const [listed, ref] = await withGitAbortSignal(signal, () => Promise.all([
|
|
2188
|
+
gitTry(['-C', root, 'worktree', 'list', '--porcelain', '-z']),
|
|
2189
|
+
gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
|
|
2190
|
+
]))
|
|
2191
|
+
if (!listed.ok) throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read worktree registry: ${listed.stderr.trim() || listed.failure}`, 500)
|
|
2192
|
+
if (!ref.ok && ref.failure !== 'exit') throw new SessionCreateError('session_create_failed', 'git-worktree', `cannot read candidate branch: ${ref.stderr.trim() || ref.failure}`, 500)
|
|
2193
|
+
return { path: existsSync(path), worktree: listed.stdout.split('\0').includes(`worktree ${path}`), branch: ref.ok }
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
async function cleanupSessionCandidate(root: string, id: string, path: string, branch: string, owned: SessionCandidateOwnership): Promise<string[]> {
|
|
2197
|
+
const residues: string[] = []
|
|
2198
|
+
if (owned.store) {
|
|
2199
|
+
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* verified below */ }
|
|
2200
|
+
}
|
|
2201
|
+
const controller = new AbortController()
|
|
2202
|
+
const timer = setTimeout(() => controller.abort(), 10_000)
|
|
2203
|
+
timer.unref?.()
|
|
2204
|
+
try {
|
|
2205
|
+
await withGitAbortSignal(controller.signal, async () => {
|
|
2206
|
+
if (owned.worktree) {
|
|
2207
|
+
const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', path])
|
|
2208
|
+
if (!removed.ok) residues.push(`worktree remove failed: ${removed.stderr.trim() || removed.failure}`)
|
|
2209
|
+
}
|
|
2210
|
+
const afterRemove = await sessionCandidateState(root, path, branch, controller.signal)
|
|
2211
|
+
if (owned.path && afterRemove.path && !afterRemove.worktree) {
|
|
2212
|
+
try { rmSync(path, { recursive: true, force: true }) } catch { /* verified below */ }
|
|
2213
|
+
}
|
|
2214
|
+
const ref = `refs/heads/${branch}`
|
|
2215
|
+
if (owned.branch) {
|
|
2216
|
+
const deleted = await gitTry(['-C', root, 'branch', '-D', branch])
|
|
2217
|
+
if (!deleted.ok) residues.push(`branch delete failed: ${deleted.stderr.trim() || deleted.failure}`)
|
|
2218
|
+
}
|
|
2219
|
+
const state = await sessionCandidateState(root, path, branch, controller.signal)
|
|
2220
|
+
if ((owned.path && state.path) || (owned.worktree && state.worktree)) residues.push(`owned worktree remains at ${path}`)
|
|
2221
|
+
if (owned.branch && state.branch) residues.push(`owned branch remains at ${ref}`)
|
|
2222
|
+
if ((!owned.path && state.path) || (!owned.worktree && state.worktree)) residues.push(`unowned candidate worktree preserved at ${path}`)
|
|
2223
|
+
if (!owned.branch && state.branch) residues.push(`unowned candidate branch preserved at ${ref}`)
|
|
2224
|
+
})
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
residues.push(`Git cleanup did not settle: ${error instanceof Error ? error.message : error}`)
|
|
2227
|
+
} finally { clearTimeout(timer) }
|
|
2228
|
+
if (owned.store && existsSync(sessionStoreDir(id))) residues.push(`owned session store remains at ${sessionStoreDir(id)}`)
|
|
2229
|
+
return residues
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function existingCreateReceipt(rec: SessRec): Session {
|
|
2233
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
2234
|
+
if (rec.status === 'queued') return toSession(rec, 'queued', 'offline')
|
|
2235
|
+
const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status
|
|
2236
|
+
return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting')
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
async function proveSessionCandidate(path: string, branch: string, signal: AbortSignal): Promise<string | null> {
|
|
2240
|
+
const [top, checkedOut, ref] = await withGitAbortSignal(signal, () => Promise.all([
|
|
2241
|
+
gitTry(['-C', path, 'rev-parse', '--show-toplevel']),
|
|
2242
|
+
gitTry(['-C', path, 'symbolic-ref', '--quiet', '--short', 'HEAD']),
|
|
2243
|
+
gitTry(['-C', mainRoot(), 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]),
|
|
2244
|
+
]))
|
|
2245
|
+
if (!top.ok || !checkedOut.ok || !ref.ok) return [top.stderr, checkedOut.stderr, ref.stderr].map((value) => value.trim()).filter(Boolean).join('; ') || 'Git identity validation failed'
|
|
2246
|
+
let actualTop = top.stdout.trim()
|
|
2247
|
+
try { actualTop = realpathSync(actualTop) } catch { /* missing path is reported by the comparison */ }
|
|
2248
|
+
let expectedTop = path
|
|
2249
|
+
try { expectedTop = realpathSync(path) } catch { /* missing path is reported by the comparison */ }
|
|
2250
|
+
if (actualTop !== expectedTop) return `worktree top-level is ${actualTop}, expected ${expectedTop}`
|
|
2251
|
+
if (checkedOut.stdout.trim() !== branch) return `worktree checked out ${checkedOut.stdout.trim() || '(detached)'}, expected ${branch}`
|
|
2252
|
+
return null
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// @@@ prepareSession - private required-context half of the bounded create owner. It prepares one durable
|
|
2256
|
+
// worktree (branch node/<slug> off main) + global session.json record. The agent does NOT
|
|
1872
2257
|
// launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
|
|
1873
2258
|
// immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
|
|
1874
2259
|
// `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
|
|
1875
2260
|
// launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
|
|
1876
2261
|
// only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
|
|
1877
2262
|
// programmatic surface, every other surface is prompt only).
|
|
1878
|
-
|
|
1879
|
-
const id =
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2263
|
+
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
|
|
2264
|
+
const { id, requestDigest, payloadHash, signal } = context
|
|
2265
|
+
let phase: SessionCreatePhase = 'creation-lock'
|
|
2266
|
+
let shouldDrain = false
|
|
2267
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2268
|
+
try {
|
|
2269
|
+
const receipt = await withRecordLock(id, async () => {
|
|
2270
|
+
const existing = readRecord(id)
|
|
2271
|
+
if (existing) {
|
|
2272
|
+
if (existing.createRequestId !== requestDigest || existing.createPayloadHash !== payloadHash) {
|
|
2273
|
+
throw new SessionCreateError('session_create_key_reused', 'creation-lock', 'Idempotency-Key is already bound to another session-create payload', 409)
|
|
2274
|
+
}
|
|
2275
|
+
await retirePublishedSessionCandidateReceipt(existing, context)
|
|
2276
|
+
return existingCreateReceipt(existing)
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
phase = 'launcher-resolution'
|
|
2280
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2281
|
+
let chosen: ReturnType<typeof resolveLauncher>
|
|
2282
|
+
let h: Harness
|
|
2283
|
+
let pinned: string
|
|
2284
|
+
try {
|
|
2285
|
+
const lname = launcher ?? defaultLauncher(mainRoot())
|
|
2286
|
+
chosen = resolveLauncher(lname)
|
|
2287
|
+
h = harnessById(chosen.harness)
|
|
2288
|
+
pinned = h.baseCmd(chosen.cmd)
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
|
|
2291
|
+
}
|
|
2292
|
+
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2293
|
+
|
|
2294
|
+
phase = 'target-resolution'
|
|
2295
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2296
|
+
throwIfCreateAborted(signal, phase)
|
|
2297
|
+
const rawPrompt = prompt
|
|
2298
|
+
const ref = nodeFromPrompt(rawPrompt)
|
|
2299
|
+
const launchSpecs = ref ? loadSpecsLite() : null
|
|
2300
|
+
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
2301
|
+
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
2302
|
+
const root = mainRoot()
|
|
2303
|
+
const branch = `${readConfig(dirname(gitCommonDir())).branchPrefix ?? 'node/'}${slug}`
|
|
2304
|
+
const path = join(root, '.worktrees', slug)
|
|
2305
|
+
const spec = ref ? launchSpecs?.find((node) => node.id === ref) : undefined
|
|
2306
|
+
const suffix = (spec ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.` : '')
|
|
2307
|
+
+ spawnerClause(parent ? readRecord(parent) : null)
|
|
2308
|
+
let launchPrompt: string
|
|
2309
|
+
try {
|
|
2310
|
+
launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
|
|
2311
|
+
loadedSpecs: launchSpecs ?? undefined,
|
|
2312
|
+
suffix: suffix || undefined,
|
|
2313
|
+
})).text
|
|
2314
|
+
} catch (error) {
|
|
2315
|
+
throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400)
|
|
2316
|
+
}
|
|
2317
|
+
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2318
|
+
|
|
2319
|
+
phase = 'git-worktree'
|
|
2320
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2321
|
+
const resourceLock = sessionCandidateLockId(path, branch)
|
|
2322
|
+
return await withRecordLock(resourceLock, async () => {
|
|
2323
|
+
throwIfCreateAborted(signal, phase)
|
|
2324
|
+
let before = await sessionCandidateState(root, path, branch, signal)
|
|
2325
|
+
let storePresent = existsSync(sessionStoreDir(id))
|
|
2326
|
+
const durable = readSessionCandidateReceipt(id)
|
|
2327
|
+
if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
|
|
2328
|
+
if (durable.kind === 'valid') {
|
|
2329
|
+
if (!sessionCandidateReceiptMatches(durable.receipt, context, root, path, branch)) {
|
|
2330
|
+
throw new SessionCreateError('session_create_failed', phase, 'private candidate receipt does not match this create request; preserving candidate resources', 409)
|
|
2331
|
+
}
|
|
2332
|
+
phase = 'cleanup'
|
|
2333
|
+
traceSessionCreate(id, requestDigest, phase, 'start', `recover-${durable.receipt.stage}`)
|
|
2334
|
+
const recovered = await cleanupSessionCandidate(root, id, path, branch, {
|
|
2335
|
+
store: storePresent, path: before.path, worktree: before.worktree, branch: before.branch,
|
|
2336
|
+
})
|
|
2337
|
+
traceSessionCreate(id, requestDigest, phase, recovered.length ? 'abort' : 'finish', recovered.join('; ') || undefined)
|
|
2338
|
+
if (recovered.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `matching candidate recovery left residue: ${recovered.join('; ')}`, 500)
|
|
2339
|
+
before = { path: false, worktree: false, branch: false }
|
|
2340
|
+
storePresent = false
|
|
2341
|
+
phase = 'git-worktree'
|
|
2342
|
+
} else if (storePresent || before.path || before.worktree || before.branch) {
|
|
2343
|
+
const occupied = [storePresent ? `session store ${sessionStoreDir(id)}` : '', before.path ? `path ${path}` : '', before.worktree ? `registered worktree ${path}` : '', before.branch ? `branch ${branch}` : ''].filter(Boolean).join(', ')
|
|
2344
|
+
throw new SessionCreateError('session_create_failed', phase, `session target is already occupied: ${occupied}`, 409)
|
|
2345
|
+
}
|
|
2346
|
+
let candidateReceipt: SessionCandidateReceipt = {
|
|
2347
|
+
version: 1, requestDigest, payloadHash, root, path, branch,
|
|
2348
|
+
prestate: { store: false, path: false, worktree: false, branch: false }, stage: 'prepared',
|
|
2349
|
+
}
|
|
2350
|
+
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2351
|
+
const owned: SessionCandidateOwnership = { store: false, path: false, worktree: false, branch: false }
|
|
2352
|
+
let gitMutationStarted = false
|
|
2353
|
+
let published = false
|
|
2354
|
+
try {
|
|
2355
|
+
gitMutationStarted = true
|
|
2356
|
+
const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()]))
|
|
2357
|
+
if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
|
|
2358
|
+
if (!added.ok || !existsSync(path)) {
|
|
2359
|
+
throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
|
|
2360
|
+
}
|
|
2361
|
+
candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
|
|
2362
|
+
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2363
|
+
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2364
|
+
seedWorktreeHostState(root, path)
|
|
2365
|
+
|
|
2366
|
+
let rec: SessRec = {
|
|
2367
|
+
session: id, governed: true, worktreePath: path, branch,
|
|
2368
|
+
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
2369
|
+
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
2370
|
+
harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
|
|
2371
|
+
launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
|
|
2372
|
+
}
|
|
2373
|
+
owned.store = true
|
|
2374
|
+
const dir = storeDir(id)
|
|
2375
|
+
writeFileSync(join(dir, 'prompt'), rawPrompt)
|
|
2376
|
+
writeFileSync(join(dir, 'launch'), launchPrompt)
|
|
2377
|
+
candidateReceipt = { ...candidateReceipt, stage: 'store-created' }
|
|
2378
|
+
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
2379
|
+
|
|
2380
|
+
phase = 'materialize'
|
|
2381
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2382
|
+
rec = await materializeSessionCandidate(rec, signal)
|
|
2383
|
+
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2384
|
+
|
|
2385
|
+
phase = 'record-write'
|
|
2386
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2387
|
+
throwIfCreateAborted(signal, phase)
|
|
2388
|
+
const gitMismatch = await proveSessionCandidate(path, branch, signal)
|
|
2389
|
+
if (gitMismatch) throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500)
|
|
2390
|
+
throwIfCreateAborted(signal, phase)
|
|
2391
|
+
writeRecord(rec)
|
|
2392
|
+
published = true
|
|
2393
|
+
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root)
|
|
2394
|
+
if (receiptFailure) console.error(`spex: published session ${id.slice(0, 8)} remains the fence for its candidate receipt: ${receiptFailure}`)
|
|
2395
|
+
shouldDrain = true
|
|
2396
|
+
traceSessionCreate(id, requestDigest, phase, 'publish')
|
|
2397
|
+
return toSession(rec, 'queued', 'offline')
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
if (published) throw error
|
|
2400
|
+
const failurePhase = error instanceof SessionCreateError ? error.phase : phase
|
|
2401
|
+
let ownershipFailure: string | null = null
|
|
2402
|
+
if (gitMutationStarted && !(owned.path && owned.worktree && owned.branch)) {
|
|
2403
|
+
const inspection = new AbortController()
|
|
2404
|
+
const timer = setTimeout(() => inspection.abort(), 10_000)
|
|
2405
|
+
timer.unref?.()
|
|
2406
|
+
try {
|
|
2407
|
+
const after = await sessionCandidateState(root, path, branch, inspection.signal)
|
|
2408
|
+
owned.path ||= !before.path && after.path
|
|
2409
|
+
owned.worktree ||= !before.worktree && after.worktree
|
|
2410
|
+
owned.branch ||= !before.branch && after.branch
|
|
2411
|
+
} catch (inspectionError) {
|
|
2412
|
+
ownershipFailure = `candidate ownership inspection failed: ${inspectionError instanceof Error ? inspectionError.message : inspectionError}`
|
|
2413
|
+
} finally { clearTimeout(timer) }
|
|
2414
|
+
}
|
|
2415
|
+
phase = 'cleanup'
|
|
2416
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2417
|
+
const residues = await cleanupSessionCandidate(root, id, path, branch, owned)
|
|
2418
|
+
if (ownershipFailure) residues.unshift(ownershipFailure)
|
|
2419
|
+
if (!residues.length && !retireSessionCandidateReceipt(id)) residues.push(`private candidate receipt remains at ${sessionCandidateReceiptPath(id)}`)
|
|
2420
|
+
traceSessionCreate(id, requestDigest, phase, residues.length ? 'abort' : 'finish', residues.join('; ') || undefined)
|
|
2421
|
+
if (residues.length) throw new SessionCreateError('session_create_cleanup_failed', phase, `session creation failed and cleanup left residue: ${residues.join('; ')}`, 500)
|
|
2422
|
+
if (signal.aborted) { phase = failurePhase; throw createAbortError(signal, failurePhase) }
|
|
2423
|
+
throw error instanceof SessionCreateError
|
|
2424
|
+
? error
|
|
2425
|
+
: new SessionCreateError('session_create_failed', failurePhase, error instanceof Error ? error.message : String(error), 500)
|
|
2426
|
+
}
|
|
2427
|
+
}, signal)
|
|
2428
|
+
}, signal)
|
|
2429
|
+
traceSessionCreate(id, requestDigest, 'creation-lock', 'finish')
|
|
2430
|
+
if (shouldDrain) {
|
|
2431
|
+
phase = 'launcher-queue'
|
|
2432
|
+
traceSessionCreate(id, requestDigest, phase, 'start')
|
|
2433
|
+
requestQueueDrain()
|
|
2434
|
+
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2435
|
+
}
|
|
2436
|
+
return receipt
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
const failure = signal.aborted
|
|
2439
|
+
? createAbortError(signal, phase)
|
|
2440
|
+
: error instanceof SessionCreateError
|
|
2441
|
+
? error
|
|
2442
|
+
: new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 500)
|
|
2443
|
+
traceSessionCreate(id, requestDigest, failure.phase, 'abort', failure.code)
|
|
2444
|
+
throw failure
|
|
2445
|
+
}
|
|
1945
2446
|
}
|
|
1946
2447
|
|
|
1947
2448
|
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
@@ -1956,9 +2457,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
|
|
|
1956
2457
|
try {
|
|
1957
2458
|
doMaterialize(rec.worktreePath)
|
|
1958
2459
|
} catch (e) {
|
|
1959
|
-
|
|
1960
|
-
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
|
|
1961
|
-
writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
|
|
2460
|
+
writeRecord(sessionCreateFailureRecord(rec, e))
|
|
1962
2461
|
}
|
|
1963
2462
|
}
|
|
1964
2463
|
|
|
@@ -2795,23 +3294,10 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
2795
3294
|
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
2796
3295
|
// A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
|
|
2797
3296
|
// control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
|
|
2798
|
-
async function
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
2803
|
-
const quarantined = quarantineRecord(id)
|
|
2804
|
-
const runtime = sessionStoreDir(id)
|
|
2805
|
-
const evidence = quarantined
|
|
2806
|
-
? `Original bytes were copied to ${quarantined}`
|
|
2807
|
-
: `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
|
|
2808
|
-
let guard = 'no readable session record proves the adapter or leaf owner'
|
|
2809
|
-
try { await stopAgentProcess(id, null) }
|
|
2810
|
-
catch (error) { guard = error instanceof Error ? error.message : String(error) }
|
|
2811
|
-
throw new SessionRecordUnusable('corrupt', id,
|
|
2812
|
-
`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.`)
|
|
2813
|
-
}
|
|
2814
|
-
if (!wt) return false
|
|
3297
|
+
async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
|
|
3298
|
+
const root = mainRoot()
|
|
3299
|
+
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
|
|
3300
|
+
if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
|
|
2815
3301
|
if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
|
|
2816
3302
|
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
2817
3303
|
else await stopAgentProcess(id, wt.rec)
|
|
@@ -2819,17 +3305,17 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
|
2819
3305
|
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
2820
3306
|
// a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
|
|
2821
3307
|
if (existsSync(wt.path)) {
|
|
2822
|
-
const removed = await gitTry(['-C',
|
|
3308
|
+
const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path])
|
|
2823
3309
|
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`)
|
|
2824
3310
|
if (existsSync(wt.path)) throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`)
|
|
2825
3311
|
}
|
|
2826
3312
|
if (wt.branch) {
|
|
2827
3313
|
const branchRef = `refs/heads/${wt.branch}`
|
|
2828
|
-
const present = await gitTry(['-C',
|
|
3314
|
+
const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2829
3315
|
if (present.ok) {
|
|
2830
|
-
const removed = await gitTry(['-C',
|
|
3316
|
+
const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch])
|
|
2831
3317
|
if (!removed.ok) throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`)
|
|
2832
|
-
const remaining = await gitTry(['-C',
|
|
3318
|
+
const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef])
|
|
2833
3319
|
if (remaining.ok || remaining.failure !== 'exit') throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`)
|
|
2834
3320
|
} else if (present.failure !== 'exit') {
|
|
2835
3321
|
throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`)
|
|
@@ -2842,10 +3328,223 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
|
2842
3328
|
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
2843
3329
|
return true
|
|
2844
3330
|
}
|
|
3331
|
+
async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
3332
|
+
let wt: { path: string; branch: string | null; rec: SessRec } | null = null
|
|
3333
|
+
try { wt = await findWorktree(id) }
|
|
3334
|
+
catch (e) {
|
|
3335
|
+
if (!(e instanceof SessionRecordUnusable) || e.code !== 'corrupt') throw e
|
|
3336
|
+
const quarantined = quarantineRecord(id)
|
|
3337
|
+
const runtime = sessionStoreDir(id)
|
|
3338
|
+
const evidence = quarantined
|
|
3339
|
+
? `Original bytes were copied to ${quarantined}`
|
|
3340
|
+
: `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`
|
|
3341
|
+
let guard = 'no readable session record proves the adapter or leaf owner'
|
|
3342
|
+
try { await stopAgentProcess(id, null) }
|
|
3343
|
+
catch (error) { guard = error instanceof Error ? error.message : String(error) }
|
|
3344
|
+
throw new SessionRecordUnusable('corrupt', id,
|
|
3345
|
+
`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.`)
|
|
3346
|
+
}
|
|
3347
|
+
if (!wt) return false
|
|
3348
|
+
const target = wt
|
|
3349
|
+
return target.branch
|
|
3350
|
+
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target))
|
|
3351
|
+
: closeOwnedSessionUnlocked(id, target)
|
|
3352
|
+
}
|
|
2845
3353
|
export const closeSession = (id: string): Promise<boolean> =>
|
|
2846
3354
|
runSessionOperation({ op: 'close', sessionId: id },
|
|
2847
3355
|
() => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
|
|
2848
3356
|
|
|
3357
|
+
export type CorruptRecordQuarantineWitness = {
|
|
3358
|
+
adapter: string
|
|
3359
|
+
thread: string | null
|
|
3360
|
+
tmux: string
|
|
3361
|
+
worktree: string
|
|
3362
|
+
branch: string
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
export type CorruptRecordQuarantineResult = {
|
|
3366
|
+
id: string
|
|
3367
|
+
bundle: string
|
|
3368
|
+
sha256: string
|
|
3369
|
+
observedAt: string
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
const quarantineRoot = (id: string) => join(runtimeRoot(), 'corrupt', id)
|
|
3373
|
+
const recordSha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')
|
|
3374
|
+
|
|
3375
|
+
function normalizeQuarantineWitness(id: string, raw: unknown): CorruptRecordQuarantineWitness {
|
|
3376
|
+
if (!raw || typeof raw !== 'object') throw new ResourceConflict(`refusing to quarantine ${id}: an exact adapter/thread/tmux/worktree/branch witness is required`)
|
|
3377
|
+
const value = raw as Record<string, unknown>
|
|
3378
|
+
const text = (key: keyof CorruptRecordQuarantineWitness): string => typeof value[key] === 'string' ? value[key].trim() : ''
|
|
3379
|
+
const adapter = text('adapter')
|
|
3380
|
+
const tmux = text('tmux')
|
|
3381
|
+
const worktree = text('worktree')
|
|
3382
|
+
const branch = text('branch')
|
|
3383
|
+
if (!Object.prototype.hasOwnProperty.call(value, 'thread'))
|
|
3384
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be explicit (a string or null)`)
|
|
3385
|
+
const threadValue = value.thread
|
|
3386
|
+
const thread = typeof threadValue === 'string' && threadValue.trim() ? threadValue.trim() : threadValue == null || threadValue === '' ? null : null
|
|
3387
|
+
if (!adapter || !HARNESSES.some((h) => h.id === adapter)) throw new ResourceConflict(`refusing to quarantine ${id}: adapter must name one registered harness`)
|
|
3388
|
+
if (tmux !== id) throw new ResourceConflict(`refusing to quarantine ${id}: tmux witness must be the exact session id ${id}`)
|
|
3389
|
+
if (!worktree || !isAbsolute(worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: worktree witness must be an absolute path`)
|
|
3390
|
+
if (!branch || branch.startsWith('-') || branch.startsWith('refs/')) throw new ResourceConflict(`refusing to quarantine ${id}: branch witness must be one local branch name`)
|
|
3391
|
+
if (threadValue !== undefined && threadValue !== null && typeof threadValue !== 'string') throw new ResourceConflict(`refusing to quarantine ${id}: thread witness must be a string or null`)
|
|
3392
|
+
return { adapter, thread, tmux, worktree: resolve(worktree), branch }
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
async function proveQuarantineTmuxAbsent(id: string): Promise<{ state: 'absent' }> {
|
|
3396
|
+
try { await tmux(['has-session', '-t', id], TMUX_PROBE_TIMEOUT_MS) }
|
|
3397
|
+
catch (error) {
|
|
3398
|
+
if (probeTimedOut(error)) throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (probe timed out)`)
|
|
3399
|
+
if (typeof (error as NodeJS.ErrnoException).code === 'number') return { state: 'absent' }
|
|
3400
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: tmux absence is unknown (${error instanceof Error ? error.message : String(error)})`)
|
|
3401
|
+
}
|
|
3402
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: exact tmux session ${id} is live`)
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
async function proveQuarantineGitAbsent(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
|
|
3406
|
+
worktree: { state: 'absent' }
|
|
3407
|
+
branch: { state: 'absent' }
|
|
3408
|
+
}> {
|
|
3409
|
+
if (existsSync(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} is live`)
|
|
3410
|
+
const root = mainRoot()
|
|
3411
|
+
const listed = await gitTry(['-C', root, 'worktree', 'list', '--porcelain'])
|
|
3412
|
+
if (!listed.ok) throw new ResourceConflict(`refusing to quarantine ${id}: worktree registry is unknown`)
|
|
3413
|
+
const registered = listed.stdout.split('\n').filter((line) => line.startsWith('worktree ')).map((line) => resolve(line.slice('worktree '.length)))
|
|
3414
|
+
if (registered.includes(witness.worktree)) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed worktree ${witness.worktree} remains registered`)
|
|
3415
|
+
const branch = await gitTry(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${witness.branch}`])
|
|
3416
|
+
if (branch.ok) throw new ResourceConflict(`refusing to quarantine ${id}: witnessed branch ${witness.branch} is live`)
|
|
3417
|
+
if (branch.failure !== 'exit') throw new ResourceConflict(`refusing to quarantine ${id}: branch absence is unknown`)
|
|
3418
|
+
return { worktree: { state: 'absent' }, branch: { state: 'absent' } }
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function proveQuarantineLeafAbsent(id: string): { state: 'absent'; artifact: 'missing' | `stale:${number}` } {
|
|
3422
|
+
const path = sessionArtifactPath(id, 'agent.pid')
|
|
3423
|
+
let text: string
|
|
3424
|
+
try { text = readFileSync(path, 'utf8').trim() }
|
|
3425
|
+
catch (error) {
|
|
3426
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { state: 'absent', artifact: 'missing' }
|
|
3427
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is unreadable`)
|
|
3428
|
+
}
|
|
3429
|
+
const pid = Number(text)
|
|
3430
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new ResourceConflict(`refusing to quarantine ${id}: leaf PID artifact is malformed`)
|
|
3431
|
+
const start = processStartToken(pid)
|
|
3432
|
+
if (start) throw new ResourceConflict(`refusing to quarantine ${id}: registered agent process ${pid}@${start} is live or recycled`)
|
|
3433
|
+
return { state: 'absent', artifact: `stale:${pid}` }
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuarantineWitness): Promise<{
|
|
3437
|
+
adapter: string
|
|
3438
|
+
thread: string | null
|
|
3439
|
+
action: 'absent' | 'archived' | 'already-unloaded'
|
|
3440
|
+
compensate: () => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
3441
|
+
}> {
|
|
3442
|
+
const harness = harnessById(witness.adapter)
|
|
3443
|
+
if (harness.ownsRendezvous) {
|
|
3444
|
+
const socket = await rendezvousListening(id)
|
|
3445
|
+
if (socket === 'live') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport is live`)
|
|
3446
|
+
if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
|
|
3447
|
+
}
|
|
3448
|
+
if (witness.thread) {
|
|
3449
|
+
if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} cannot prove and unload an exact native thread`)
|
|
3450
|
+
const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
|
|
3451
|
+
if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
|
|
3452
|
+
return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
|
|
3453
|
+
}
|
|
3454
|
+
const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
3455
|
+
for (const descriptor of descriptors) {
|
|
3456
|
+
if (!descriptor.residency) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} has no exact absence census`)
|
|
3457
|
+
let residency: Awaited<ReturnType<NonNullable<typeof descriptor.residency>>>
|
|
3458
|
+
try { residency = await descriptor.residency() }
|
|
3459
|
+
catch (error) { throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${error instanceof Error ? error.message : String(error)})`) }
|
|
3460
|
+
if (!residency.healthy) throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} absence is unknown (${residency.error || 'unhealthy census'})`)
|
|
3461
|
+
if (!residency.rootAbsent || residency.referenceIds.length)
|
|
3462
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: ${descriptor.label} is live; supply its exact native thread instead of claiming absence`)
|
|
3463
|
+
}
|
|
3464
|
+
return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// @@@ quarantineCorruptRecord - the record-only escape hatch for an incident that has already lost parseability.
|
|
3468
|
+
// It never guesses from those bytes: the caller names the former residues, this function proves their absence,
|
|
3469
|
+
// and only then moves the opaque file into an auditable bundle. Close remains the destructive owner-based verb.
|
|
3470
|
+
export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
|
|
3471
|
+
return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3472
|
+
const entry = readRecordEntry(id)
|
|
3473
|
+
if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
|
|
3474
|
+
if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
|
|
3475
|
+
const witness = normalizeQuarantineWitness(id, rawWitness)
|
|
3476
|
+
const original = readFileSync(entry.path)
|
|
3477
|
+
const sha256 = recordSha256(original)
|
|
3478
|
+
const observedAt = new Date().toISOString()
|
|
3479
|
+
const leaf = proveQuarantineLeafAbsent(id)
|
|
3480
|
+
const tmux = await proveQuarantineTmuxAbsent(id)
|
|
3481
|
+
const git = await proveQuarantineGitAbsent(id, witness)
|
|
3482
|
+
const adapter = await proveQuarantineAdapter(id, witness)
|
|
3483
|
+
const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`)
|
|
3484
|
+
const stored = join(bundle, 'session.json')
|
|
3485
|
+
const provenance = join(bundle, 'provenance.json')
|
|
3486
|
+
const audit = {
|
|
3487
|
+
version: 1,
|
|
3488
|
+
sessionId: id,
|
|
3489
|
+
observedAt,
|
|
3490
|
+
record: { activePath: entry.path, sha256, bytes: original.length },
|
|
3491
|
+
witness,
|
|
3492
|
+
observed: { leaf, tmux, ...git, adapter: { adapter: adapter.adapter, thread: adapter.thread, action: adapter.action } },
|
|
3493
|
+
}
|
|
3494
|
+
try {
|
|
3495
|
+
mkdirSync(bundle, { recursive: true, mode: 0o700 })
|
|
3496
|
+
const temp = `${provenance}.${process.pid}.${randomUUID()}.tmp`
|
|
3497
|
+
writeFileSync(temp, `${JSON.stringify(audit, null, 2)}\n`, { mode: 0o600 })
|
|
3498
|
+
renameSync(temp, provenance)
|
|
3499
|
+
const current = readRecordEntry(id)
|
|
3500
|
+
if (current.kind !== 'corrupt' || current.path !== entry.path) throw new ResourceConflict(`refusing to quarantine ${id}: active record changed during absence verification`)
|
|
3501
|
+
const currentBytes = readFileSync(current.path)
|
|
3502
|
+
if (recordSha256(currentBytes) !== sha256) throw new ResourceConflict(`refusing to quarantine ${id}: opaque record changed during absence verification`)
|
|
3503
|
+
renameSync(current.path, stored)
|
|
3504
|
+
if (recordSha256(readFileSync(stored)) !== sha256) {
|
|
3505
|
+
renameSync(stored, current.path)
|
|
3506
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: moved record failed byte-exact verification`)
|
|
3507
|
+
}
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
const restored = await adapter.compensate()
|
|
3510
|
+
const suffix = restored.ok ? '' : `; native orphan compensation failed: ${restored.reason}`
|
|
3511
|
+
if (error instanceof ResourceConflict) throw new ResourceConflict(`${error.message}${suffix}`)
|
|
3512
|
+
throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
|
|
3513
|
+
}
|
|
3514
|
+
return { id, bundle, sha256, observedAt }
|
|
3515
|
+
})))
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
|
|
3519
|
+
return runSessionOperation({ op: 'quarantine', sessionId: id }, () => withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3520
|
+
if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
|
|
3521
|
+
let bundles: string[]
|
|
3522
|
+
try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
|
|
3523
|
+
catch (error) {
|
|
3524
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`)
|
|
3525
|
+
throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`)
|
|
3526
|
+
}
|
|
3527
|
+
const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')))
|
|
3528
|
+
if (!bundle) throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`)
|
|
3529
|
+
const stored = join(bundle, 'session.json')
|
|
3530
|
+
let provenance: { sessionId?: unknown; record?: { sha256?: unknown } }
|
|
3531
|
+
try { provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8')) }
|
|
3532
|
+
catch { throw new ResourceConflict(`refusing to restore ${id}: quarantine provenance is unreadable`) }
|
|
3533
|
+
const bytes = readFileSync(stored)
|
|
3534
|
+
if (provenance.sessionId !== id || typeof provenance.record?.sha256 !== 'string' || provenance.record.sha256 !== recordSha256(bytes))
|
|
3535
|
+
throw new ResourceConflict(`refusing to restore ${id}: quarantine payload/provenance binding is invalid`)
|
|
3536
|
+
const active = sessionRecordPath(id)
|
|
3537
|
+
mkdirSync(dirname(active), { recursive: true })
|
|
3538
|
+
try { renameSync(stored, active) }
|
|
3539
|
+
catch (error) { throw new ResourceConflict(`refusing to restore ${id}: opaque record move failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
3540
|
+
if (recordSha256(readFileSync(active)) !== provenance.record.sha256) {
|
|
3541
|
+
renameSync(active, stored)
|
|
3542
|
+
throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
|
|
3543
|
+
}
|
|
3544
|
+
return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
|
|
3545
|
+
})))
|
|
3546
|
+
}
|
|
3547
|
+
|
|
2849
3548
|
// @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
|
|
2850
3549
|
// evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
|
|
2851
3550
|
// session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
|
|
@@ -3165,7 +3864,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
3165
3864
|
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
3166
3865
|
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
3167
3866
|
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3168
|
-
async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
3867
|
+
async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
|
|
3169
3868
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3170
3869
|
const rec = readRecord(id)
|
|
3171
3870
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
@@ -3181,7 +3880,7 @@ async function sendTextUnlocked(id: string, text: string, from?: string, opts: {
|
|
|
3181
3880
|
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
3182
3881
|
}
|
|
3183
3882
|
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3184
|
-
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
|
|
3883
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), ...(opts.deliveryId ? { deliveryId: opts.deliveryId } : {}) }, prompt.text)
|
|
3185
3884
|
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
3186
3885
|
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
3187
3886
|
if (r.ok && from) void recordComms(id, from)
|
|
@@ -3189,10 +3888,69 @@ async function sendTextUnlocked(id: string, text: string, from?: string, opts: {
|
|
|
3189
3888
|
if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
|
|
3190
3889
|
return r
|
|
3191
3890
|
}
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3891
|
+
|
|
3892
|
+
// A native request can commit after its client loses the response. Reserve a caller's opaque marker BEFORE
|
|
3893
|
+
// crossing that boundary, under the session lock, so a retry has one safe answer after a backend restart: the
|
|
3894
|
+
// stored terminal result, or commit-unknown for a reservation whose result could not be durably recorded.
|
|
3895
|
+
// This is session-generic idempotency; adapters only decide whether they can also carry the marker natively.
|
|
3896
|
+
type DeliveryLedgerEntry = { deliveryId: string; fingerprint: string; result?: DispatchResult }
|
|
3897
|
+
const deliveryLedgerPath = (id: string) => sessionArtifactPath(id, 'deliveries.ndjson')
|
|
3898
|
+
const deliveryFingerprint = (text: string, from?: string, replyVia?: 'note') =>
|
|
3899
|
+
createHash('sha256').update(JSON.stringify([text, from ?? null, replyVia ?? null])).digest('hex')
|
|
3900
|
+
function readDeliveryLedger(id: string, deliveryId: string): DeliveryLedgerEntry | null {
|
|
3901
|
+
try {
|
|
3902
|
+
const lines = readFileSync(deliveryLedgerPath(id), 'utf8').split('\n')
|
|
3903
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
3904
|
+
try {
|
|
3905
|
+
const entry = JSON.parse(lines[index]) as DeliveryLedgerEntry
|
|
3906
|
+
if (entry?.deliveryId === deliveryId && typeof entry.fingerprint === 'string') return entry
|
|
3907
|
+
} catch { /* one corrupt append must not invent a delivery result */ }
|
|
3908
|
+
}
|
|
3909
|
+
} catch { /* no delivery ledger yet */ }
|
|
3910
|
+
return null
|
|
3911
|
+
}
|
|
3912
|
+
function appendDeliveryLedger(id: string, entry: DeliveryLedgerEntry): void {
|
|
3913
|
+
appendFileSync(join(storeDir(id), 'deliveries.ndjson'), JSON.stringify(entry) + '\n')
|
|
3914
|
+
}
|
|
3915
|
+
function terminalDispatch(result: DispatchResult): DispatchResult {
|
|
3916
|
+
return { ...result, outcome: result.outcome ?? (result.ok ? 'accepted' : 'rejected') }
|
|
3917
|
+
}
|
|
3918
|
+
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
|
|
3919
|
+
// The lock owns only the durable reservation and result. Codex's native turn can synchronously run hooks
|
|
3920
|
+
// that write this same record, so holding it across an adapter RPC deadlocks the app-server's confirmation.
|
|
3921
|
+
return runSessionOperation({ op: 'send', sessionId: id }, async () => {
|
|
3922
|
+
const deliveryId = opts.deliveryId?.trim()
|
|
3923
|
+
if (!deliveryId) return terminalDispatch(await sendTextUnlocked(id, text, from, opts))
|
|
3924
|
+
const fingerprint = deliveryFingerprint(text, from, opts.replyVia)
|
|
3925
|
+
const prior = await withRecordLock(id, async () => {
|
|
3926
|
+
const existing = readDeliveryLedger(id, deliveryId)
|
|
3927
|
+
if (existing) return existing
|
|
3928
|
+
try {
|
|
3929
|
+
appendDeliveryLedger(id, { deliveryId, fingerprint })
|
|
3930
|
+
return null
|
|
3931
|
+
} catch (error) {
|
|
3932
|
+
return { deliveryId, fingerprint, result: { ok: false, outcome: 'rejected', error: `could not reserve delivery marker: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` } satisfies DispatchResult }
|
|
3933
|
+
}
|
|
3934
|
+
})
|
|
3935
|
+
if (prior) {
|
|
3936
|
+
if (prior.fingerprint !== fingerprint)
|
|
3937
|
+
return { ok: false, outcome: 'rejected', error: 'delivery marker belongs to a different prompt — prompt NOT delivered' }
|
|
3938
|
+
return prior.result
|
|
3939
|
+
? terminalDispatch(prior.result)
|
|
3940
|
+
: { ok: false, outcome: 'commit-unknown', error: 'delivery marker is already reserved without a terminal outcome — prompt NOT replayed' }
|
|
3941
|
+
}
|
|
3942
|
+
const result = terminalDispatch(await sendTextUnlocked(id, text, from, { ...opts, deliveryId }))
|
|
3943
|
+
try {
|
|
3944
|
+
return await withRecordLock(id, async () => {
|
|
3945
|
+
if (!readRecord(id))
|
|
3946
|
+
return { ok: false, outcome: 'commit-unknown', error: 'session closed before delivery outcome could be recorded — prompt NOT replayed' }
|
|
3947
|
+
appendDeliveryLedger(id, { deliveryId, fingerprint, result })
|
|
3948
|
+
return result
|
|
3949
|
+
})
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
return { ok: false, outcome: 'commit-unknown', error: `delivery outcome could not be recorded: ${error instanceof Error ? error.message : String(error)} — prompt NOT replayed` }
|
|
3952
|
+
}
|
|
3953
|
+
})
|
|
3196
3954
|
}
|
|
3197
3955
|
|
|
3198
3956
|
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|