spexcode 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/client.ts
CHANGED
|
@@ -2,26 +2,113 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { platform } from 'node:os'
|
|
3
3
|
import { repoRoot } from './git.js'
|
|
4
4
|
import { resourceBudgets, type ResourceReport } from './host-resources.js'
|
|
5
|
-
import { listSessionIds, readPublicRecordEntry } from './layout.js'
|
|
5
|
+
import { envSessionId, listSessionIds, readPublicRecordEntry } from './layout.js'
|
|
6
6
|
import { cockpitReview, type CockpitReview } from './cockpit.js'
|
|
7
|
-
import {
|
|
7
|
+
import { apiBaseInfo, assertProjectMatch, fromRaw, optionArgv, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
8
8
|
|
|
9
9
|
export class BackendError extends Error {
|
|
10
|
-
constructor(message: string, readonly status?: number) {
|
|
10
|
+
constructor(message: string, readonly status?: number, readonly transport?: unknown) {
|
|
11
11
|
super(message)
|
|
12
12
|
this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const usageError = (message: string): Error => {
|
|
17
|
+
const error = new Error(message)
|
|
18
|
+
error.name = 'UsageError'
|
|
19
|
+
return error
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hasFlag = (name: string): boolean => optionArgv().includes(`--${name}`)
|
|
23
|
+
function flagValue(name: string): string | null {
|
|
24
|
+
const argv = optionArgv()
|
|
25
|
+
const index = argv.indexOf(`--${name}`)
|
|
26
|
+
if (index < 0) return null
|
|
27
|
+
const value = argv[index + 1]
|
|
28
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`--${name} expects a value`)
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let gatewayCookie: { base: string; value: string } | null = null
|
|
33
|
+
|
|
34
|
+
function prepareTls(base: string): void {
|
|
35
|
+
if (!hasFlag('insecure')) return
|
|
36
|
+
if (new URL(base).protocol !== 'https:') throw usageError('--insecure requires an https --api endpoint')
|
|
37
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function passwordFor(target: Awaited<ReturnType<typeof apiBaseInfo>>): string | null {
|
|
41
|
+
const password = flagValue('password') ?? process.env.SPEXCODE_PASSWORD ?? null
|
|
42
|
+
if (!password) return null
|
|
43
|
+
if (target.source !== 'flag') throw usageError('--password and SPEXCODE_PASSWORD require explicit --api routing')
|
|
44
|
+
return password
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function withCookie(init: RequestInit | undefined, cookie: string): RequestInit {
|
|
48
|
+
const headers = new Headers(init?.headers)
|
|
49
|
+
headers.set('cookie', cookie)
|
|
50
|
+
return { ...init, headers }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loginGateway(base: string, password: string): Promise<string> {
|
|
54
|
+
let response: Response
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(`${base}/login`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
59
|
+
body: new URLSearchParams({ password }).toString(),
|
|
60
|
+
redirect: 'manual',
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new BackendError(`gateway login could not reach ${base}: ${(error as Error).message}`, undefined, error)
|
|
64
|
+
}
|
|
65
|
+
if (response.status < 200 || response.status >= 400) throw new BackendError(`gateway login rejected credentials at ${base}`, response.status)
|
|
66
|
+
const headers = response.headers as Headers & { getSetCookie?: () => string[] }
|
|
67
|
+
const cookies = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']
|
|
68
|
+
const cookie = cookies.map((value) => value.split(';', 1)[0]).find(Boolean)
|
|
69
|
+
if (!cookie) throw new BackendError(`gateway login at ${base} did not return an authorization cookie`, response.status)
|
|
70
|
+
return cookie
|
|
71
|
+
}
|
|
72
|
+
|
|
16
73
|
// the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
|
|
17
74
|
// is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
|
|
18
75
|
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
76
|
+
const target = await apiBaseInfo()
|
|
77
|
+
const base = target.url
|
|
78
|
+
prepareTls(base)
|
|
79
|
+
const request = async (cookie?: string): Promise<Response> => {
|
|
80
|
+
try {
|
|
81
|
+
return await fetch(`${base}${path}`, cookie ? withCookie(init, cookie) : init)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(error as Error).message})`, undefined, error)
|
|
84
|
+
}
|
|
24
85
|
}
|
|
86
|
+
const existing = gatewayCookie?.base === base ? gatewayCookie.value : undefined
|
|
87
|
+
const response = await request(existing)
|
|
88
|
+
if (response.status !== 401) return response
|
|
89
|
+
if (existing) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
|
|
90
|
+
const password = passwordFor(target)
|
|
91
|
+
if (!password) throw new BackendError(`authentication required at ${base} — pass --password <pw> or set SPEXCODE_PASSWORD`, 401)
|
|
92
|
+
const cookie = await loginGateway(base, password)
|
|
93
|
+
gatewayCookie = { base, value: cookie }
|
|
94
|
+
const retried = await request(cookie)
|
|
95
|
+
if (retried.status === 401) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
|
|
96
|
+
return retried
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function backendConnectionRefused(error: unknown): boolean {
|
|
100
|
+
if (!(error instanceof BackendError)) return false
|
|
101
|
+
let current = error.transport
|
|
102
|
+
let sawRefusal = false
|
|
103
|
+
const seen = new Set<unknown>()
|
|
104
|
+
while (current && typeof current === 'object' && !seen.has(current)) {
|
|
105
|
+
seen.add(current)
|
|
106
|
+
const code = (current as NodeJS.ErrnoException).code
|
|
107
|
+
if (code && code !== 'ECONNREFUSED') return false
|
|
108
|
+
if (code === 'ECONNREFUSED') sawRefusal = true
|
|
109
|
+
current = (current as { cause?: unknown }).cause
|
|
110
|
+
}
|
|
111
|
+
return sawRefusal
|
|
25
112
|
}
|
|
26
113
|
// every MUTATING verb is project-bound ([[remote-client]]'s write guard): resolve the backend, compare its
|
|
27
114
|
// served root to the cwd project, refuse loudly on a same-host mismatch — an explicit --api/--port skips it.
|
|
@@ -41,7 +128,7 @@ function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
|
|
|
41
128
|
function corruptCachedSession(id: string, reason: string): Session {
|
|
42
129
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
43
130
|
return {
|
|
44
|
-
id, node: null, branch: null, path: '', label,
|
|
131
|
+
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
45
132
|
parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
|
|
46
133
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
47
134
|
note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
|
|
@@ -211,10 +298,14 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
|
|
|
211
298
|
throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
|
|
212
299
|
}
|
|
213
300
|
|
|
214
|
-
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH
|
|
215
|
-
|
|
301
|
+
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH. The caller returns the exact pair it just
|
|
302
|
+
// reviewed and one durable request key; the backend accepts that decision once before ensuring the agent live.
|
|
303
|
+
export async function clientMerge(id: string, options: { expectedBranchHead: string; expectedBaseHead: string; requestKey: string }): Promise<{ dispatched: boolean; replayed?: boolean; expectedBranchHead?: string; expectedBaseHead?: string; reason?: string; code?: string }> {
|
|
216
304
|
await guarded('merge')
|
|
217
|
-
const
|
|
305
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', 'Idempotency-Key': options.requestKey }
|
|
306
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, {
|
|
307
|
+
method: 'POST', headers, body: JSON.stringify({ expectedBranchHead: options.expectedBranchHead, expectedBaseHead: options.expectedBaseHead }),
|
|
308
|
+
})
|
|
218
309
|
return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
|
|
219
310
|
}
|
|
220
311
|
|
|
@@ -245,10 +336,11 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
|
|
|
245
336
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
246
337
|
}
|
|
247
338
|
|
|
248
|
-
// POST /api/sessions/:id/close —
|
|
339
|
+
// POST /api/sessions/:id/close — terminal worktree removal. A client-side session id is only an unverified claim.
|
|
249
340
|
export async function clientClose(id: string): Promise<boolean> {
|
|
250
341
|
await guarded('session close')
|
|
251
|
-
const
|
|
342
|
+
const source = envSessionId()
|
|
343
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({ source: source ? { kind: 'unverified-session-claim', id: source } : { kind: 'user' } }))
|
|
252
344
|
if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
|
|
253
345
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
254
346
|
}
|
|
@@ -291,6 +383,13 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
|
|
|
291
383
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
292
384
|
}
|
|
293
385
|
|
|
386
|
+
export async function clientReparent(children: string[], parent: string): Promise<import('./sessions.js').SessionReparentResult> {
|
|
387
|
+
await guarded('session reparent')
|
|
388
|
+
const r = await apiFetch('/api/sessions/reparent', post({ children, parent }))
|
|
389
|
+
if (!r.ok) throw new BackendError(`backend refused to reparent sessions: ${await r.text()}`, r.status)
|
|
390
|
+
return await r.json() as import('./sessions.js').SessionReparentResult
|
|
391
|
+
}
|
|
392
|
+
|
|
294
393
|
// POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
|
|
295
394
|
// NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
|
|
296
395
|
// ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
|
|
@@ -3,7 +3,7 @@ import { createConnection, type Socket } from 'node:net'
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
|
-
import { processStartToken, verifyDetachedRuntime } from './process-identity.js'
|
|
6
|
+
import { detachedRuntimeIsGone, processStartToken, verifyDetachedRuntime } from './process-identity.js'
|
|
7
7
|
|
|
8
8
|
export type CodexGenerationEndpoint = Readonly<{
|
|
9
9
|
id: string
|
|
@@ -271,6 +271,32 @@ function hasLegacyResidue(root: string): boolean {
|
|
|
271
271
|
return [endpoint.pidFile, endpoint.receiptFile, endpoint.socketPath].some(existsSync)
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// A generation whose recorded process is provably gone — the host restarted, the OOM killer fired, a /tmp sweep
|
|
275
|
+
// took the socket with it. Death is a POSITIVE fact, not the absence of proof: such a root holds no threads, no
|
|
276
|
+
// peers, and no protective references, so nothing it once carried can be lost by retiring it. Everything else
|
|
277
|
+
// that fails `endpointIdentity` is AMBIGUOUS — a live process we can no longer address — and keeps its root.
|
|
278
|
+
function goneGeneration(endpoint: CodexGenerationEndpoint): boolean {
|
|
279
|
+
if (endpointIdentity(endpoint)) return false
|
|
280
|
+
let pid: number
|
|
281
|
+
try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) }
|
|
282
|
+
catch { return false }
|
|
283
|
+
return Number.isInteger(pid) && pid > 0 && detachedRuntimeIsGone(pid, endpoint.receiptFile)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Retire a gone generation inside the caller's ledger lock, returning the published ledger (null when the
|
|
287
|
+
// generation is still there to serve or merely unaddressable). Its bindings deliberately survive: a session's
|
|
288
|
+
// conversation lives in Codex's on-disk rollout, so those rows are stale pointers to re-pin, not lost threads.
|
|
289
|
+
function retireGoneGenerationLocked(root: string, previous: CodexGenerationLedger, id: string): CodexGenerationLedger | null {
|
|
290
|
+
const generation = previous.generations[id]
|
|
291
|
+
if (!generation || generation.state === 'reclaimed' || !goneGeneration(generation.endpoint)) return null
|
|
292
|
+
return writeLedger(root, previous, {
|
|
293
|
+
current: previous.current === id ? null : previous.current,
|
|
294
|
+
pending: previous.pending === id ? null : previous.pending,
|
|
295
|
+
generations: { ...previous.generations, [id]: { state: 'reclaimed', endpoint: generation.endpoint } },
|
|
296
|
+
bindings: previous.bindings,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
274
300
|
function bootstrapBindings(root: string, generationId: string): Record<string, CodexGenerationBinding> {
|
|
275
301
|
const result: Record<string, CodexGenerationBinding> = {}
|
|
276
302
|
const sessions = join(root, 'sessions')
|
|
@@ -305,13 +331,18 @@ function bindingProtectsGeneration(root: string, sessionId: string, binding: Cod
|
|
|
305
331
|
function bootstrapLedger(root: string): CodexGenerationLedger {
|
|
306
332
|
const legacy = legacyCodexGenerationEndpoint(root)
|
|
307
333
|
if (!hasLegacyResidue(root)) return emptyLedger()
|
|
308
|
-
|
|
334
|
+
// Residue left by a legacy root that has since died enters the ledger already reclaimed rather than blocking
|
|
335
|
+
// the bootstrap: it has nothing to drain. Its governed bindings are still recorded so those sessions resolve
|
|
336
|
+
// to a stale pointer they can re-pin, instead of losing the thread they own.
|
|
337
|
+
const gone = goneGeneration(legacy)
|
|
338
|
+
if (!gone && !endpointIdentity(legacy))
|
|
339
|
+
throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
|
|
309
340
|
return {
|
|
310
341
|
version: 3,
|
|
311
342
|
revision: 0,
|
|
312
343
|
current: null,
|
|
313
344
|
pending: null,
|
|
314
|
-
generations: { [legacy.id]: { state: 'draining', endpoint: legacy } },
|
|
345
|
+
generations: { [legacy.id]: { state: gone ? 'reclaimed' : 'draining', endpoint: legacy } },
|
|
315
346
|
bindings: bootstrapBindings(root, legacy.id),
|
|
316
347
|
}
|
|
317
348
|
}
|
|
@@ -373,9 +404,13 @@ export async function ensureCodexCurrentGeneration(root: string, start: (endpoin
|
|
|
373
404
|
}
|
|
374
405
|
if (previous.current) {
|
|
375
406
|
const generation = previous.generations[previous.current]
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
407
|
+
if (generation && generation.state === 'current' && endpointIdentity(generation.endpoint))
|
|
408
|
+
return { kind: 'current', endpoint: generation.endpoint }
|
|
409
|
+
// A canonical root that is provably gone is retired here and replaced by the rest of this same call:
|
|
410
|
+
// refusing forever would make one host restart permanently un-routable. Anything else stays a refusal.
|
|
411
|
+
const healed = retireGoneGenerationLocked(root, previous, previous.current)
|
|
412
|
+
if (!healed) throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
|
|
413
|
+
previous = healed
|
|
379
414
|
}
|
|
380
415
|
if (previous.pending) {
|
|
381
416
|
const pending = previous.generations[previous.pending]
|
|
@@ -491,12 +526,67 @@ export function prepareCodexGenerationClose(root: string, sessionId: string, thr
|
|
|
491
526
|
if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact generation binding to close`)
|
|
492
527
|
if (binding.phase === 'record-removing') return
|
|
493
528
|
const generation = previous.generations[binding.generationId]
|
|
494
|
-
|
|
529
|
+
// A retired root can neither route this thread nor be pinned by it, so there is nothing for the removal
|
|
530
|
+
// marker to protect: drop the binding outright and let close proceed. An ABSENT generation is corruption.
|
|
531
|
+
if (!generation) throw new Error(`Codex session ${sessionId} binding names an absent generation`)
|
|
532
|
+
if (generation.state === 'reclaimed') {
|
|
533
|
+
const remaining = { ...previous.bindings }
|
|
534
|
+
delete remaining[sessionId]
|
|
535
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings: remaining })
|
|
536
|
+
return
|
|
537
|
+
}
|
|
495
538
|
const bindings = { ...previous.bindings, [sessionId]: { ...binding, phase: 'record-removing' as const } }
|
|
496
539
|
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
497
540
|
})
|
|
498
541
|
}
|
|
499
542
|
|
|
543
|
+
// Re-pin an existing exact binding onto another live generation. The transaction phase is carried over, so a
|
|
544
|
+
// registration or close that is mid-flight keeps its crash boundary while its route is corrected.
|
|
545
|
+
export function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void {
|
|
546
|
+
withLedgerLockSync(root, () => {
|
|
547
|
+
const previous = readCodexGenerationLedger(root)
|
|
548
|
+
const binding = previous.bindings[sessionId]
|
|
549
|
+
if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact binding to re-pin`)
|
|
550
|
+
const generation = previous.generations[generationId]
|
|
551
|
+
if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
|
|
552
|
+
if (binding.generationId === generationId) return
|
|
553
|
+
const bindings = { ...previous.bindings, [sessionId]: { ...binding, generationId } }
|
|
554
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The resume boundary. A binding to a LIVE generation still routes only there — an existing conversation is
|
|
559
|
+
// never moved out from under the client that holds it. But a binding whose root is gone names a process, not a
|
|
560
|
+
// conversation: the thread's durable home is its on-disk rollout, which any generation can load. So the gone
|
|
561
|
+
// root is retired and the session is re-pinned to the canonical one, which loads that same rollout. Missing,
|
|
562
|
+
// mismatched, ambiguous, or unprotected bindings remain refusals — resume never invents a route.
|
|
563
|
+
export async function resolveCodexGenerationForResume(
|
|
564
|
+
root: string,
|
|
565
|
+
sessionId: string,
|
|
566
|
+
threadId: string,
|
|
567
|
+
start: (endpoint: CodexGenerationEndpoint) => Promise<void>,
|
|
568
|
+
): Promise<CodexGenerationEndpoint | null> {
|
|
569
|
+
const ledger = readCodexGenerationLedger(root)
|
|
570
|
+
const binding = ledger.bindings[sessionId]
|
|
571
|
+
if (!binding || binding.threadId !== threadId || !bindingProtectsGeneration(root, sessionId, binding)) return null
|
|
572
|
+
const generation = ledger.generations[binding.generationId]
|
|
573
|
+
if (!generation) return null
|
|
574
|
+
if (generation.state !== 'reclaimed') {
|
|
575
|
+
if (endpointIdentity(generation.endpoint)) return generation.endpoint
|
|
576
|
+
const retired = await withLedgerLock(root, async () => {
|
|
577
|
+
const previous = readCodexGenerationLedger(root)
|
|
578
|
+
if (previous.bindings[sessionId]?.generationId !== binding.generationId) return false
|
|
579
|
+
const observed = previous.generations[binding.generationId]
|
|
580
|
+
if (!observed) return false
|
|
581
|
+
return observed.state === 'reclaimed' || !!retireGoneGenerationLocked(root, previous, binding.generationId)
|
|
582
|
+
})
|
|
583
|
+
if (!retired) return null
|
|
584
|
+
}
|
|
585
|
+
const current = await ensureCodexCurrentGeneration(root, start)
|
|
586
|
+
repinCodexGeneration(root, sessionId, threadId, current.id)
|
|
587
|
+
return current
|
|
588
|
+
}
|
|
589
|
+
|
|
500
590
|
export function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null {
|
|
501
591
|
const ledger = readCodexGenerationLedger(root)
|
|
502
592
|
const binding = ledger.bindings[sessionId]
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
3
|
import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
|
|
4
4
|
import { HARNESSES } from './harness.js'
|
|
5
|
+
import { gitBinary } from './git.js'
|
|
5
6
|
|
|
6
7
|
// GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
|
|
7
8
|
// GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
|
|
@@ -9,7 +10,7 @@ import { HARNESSES } from './harness.js'
|
|
|
9
10
|
// operating on the real one would silently miss them). git.ts strips that env for repo DISCOVERY reasons;
|
|
10
11
|
// index surgery is the one place the env is the point.
|
|
11
12
|
const raw = (args: string[], input?: string): string =>
|
|
12
|
-
execFileSync(
|
|
13
|
+
execFileSync(gitBinary(process.env), args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
13
14
|
|
|
14
15
|
function inHead(p: string): boolean {
|
|
15
16
|
try { raw(['cat-file', '-e', `HEAD:${p}`]); return true } catch { return false }
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, rmSync,
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
|
-
import { git } from './git.js'
|
|
4
|
+
import { git, gitBinary } from './git.js'
|
|
5
5
|
import { writeManagedBlock, removeManagedBlock } from './harness.js'
|
|
6
6
|
import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
|
|
7
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
7
8
|
|
|
8
9
|
// the three field-sharpened edges this module owes ([[content-filter]]):
|
|
9
10
|
// ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
|
|
@@ -20,20 +21,13 @@ function commonDirOf(proj: string): string {
|
|
|
20
21
|
}
|
|
21
22
|
const filterDir = (common: string) => join(common, 'spexcode')
|
|
22
23
|
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
|
|
23
|
-
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
|
|
24
24
|
const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
|
|
25
25
|
const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
|
|
26
26
|
const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
|
|
27
27
|
const attributesPath = (common: string) => join(common, 'info', 'attributes')
|
|
28
28
|
|
|
29
|
-
const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
30
|
-
.filter((row) => row.startsWith('worktree ')).map((row) =>
|
|
31
|
-
join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
|
|
32
|
-
const legacyFilterTree = (proj: string) => registeredSlots(proj)
|
|
33
|
-
.some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
|
|
34
|
-
|
|
35
29
|
export type ContractFilterPayload = { file: string; content: string }
|
|
36
|
-
export type ContractFilterBinding = { file: string; start: string; end: string
|
|
30
|
+
export type ContractFilterBinding = { file: string; start: string; end: string }
|
|
37
31
|
|
|
38
32
|
// The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
|
|
39
33
|
// resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
|
|
@@ -48,19 +42,15 @@ set -u
|
|
|
48
42
|
mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
|
|
49
43
|
path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
|
|
50
44
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
-
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3
|
|
45
|
+
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
|
|
52
46
|
[ -n "$binding" ] || { cat; exit 0; }
|
|
53
47
|
start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
|
|
54
|
-
end="
|
|
48
|
+
end="$rest"
|
|
55
49
|
top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
56
50
|
root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
|
|
57
51
|
key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
|
|
58
52
|
manifest="$root/trees/$key/contract-filter/manifest"
|
|
59
53
|
payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
|
|
60
|
-
# A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
|
|
61
|
-
# per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
|
|
62
|
-
marker="$root/trees/$key/contract-filter-v2"
|
|
63
|
-
if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
|
|
64
54
|
strip() {
|
|
65
55
|
awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
66
56
|
{ lines[n++] = $0 }
|
|
@@ -105,33 +95,54 @@ const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
|
|
|
105
95
|
|
|
106
96
|
// plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
|
|
107
97
|
// pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
|
|
108
|
-
// binding each file to the filter.
|
|
109
|
-
//
|
|
110
|
-
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]):
|
|
98
|
+
// binding each file to the filter. `contract` is the assembled block body (guide + surface:system).
|
|
99
|
+
// settleIndexStat skips untracked entries (no index blob) by design and only runs after a real filter change.
|
|
100
|
+
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[], changedFiles: readonly string[] = []): boolean {
|
|
111
101
|
const common = commonDirOf(proj)
|
|
112
102
|
mkdirSync(filterDir(common), { recursive: true })
|
|
113
|
-
|
|
114
|
-
chmodSync(shimPath(common), 0o755)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
let changed = writeFileIfChanged(shimPath(common), SHIM)
|
|
104
|
+
if (changed || (statSync(shimPath(common)).mode & 0o777) !== 0o755) chmodSync(shimPath(common), 0o755)
|
|
105
|
+
const commands = new Map([
|
|
106
|
+
['filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')],
|
|
107
|
+
['filter.spexcode.clean', filterCmd(shimPath(common), 'clean')],
|
|
108
|
+
])
|
|
109
|
+
const configured = new Map<string, string>()
|
|
110
|
+
try {
|
|
111
|
+
for (const row of git(['-C', proj, 'config', '--get-regexp', '^filter\\.spexcode\\.(smudge|clean)$']).trimEnd().split('\n')) {
|
|
112
|
+
const at = row.indexOf(' ')
|
|
113
|
+
if (at > 0) configured.set(row.slice(0, at), row.slice(at + 1))
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
for (const [key, command] of commands) {
|
|
117
|
+
if (configured.get(key) === command) continue
|
|
118
|
+
git(['-C', proj, 'config', key, command])
|
|
119
|
+
changed = true
|
|
120
|
+
}
|
|
121
|
+
changed = writeFileIfChanged(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}`).join('\n') + '\n') || changed
|
|
118
122
|
const dir = treeFilterDir(proj)
|
|
119
|
-
|
|
123
|
+
mkdirSync(dir, { recursive: true })
|
|
120
124
|
const manifest: string[] = []
|
|
125
|
+
const wanted = new Set(['manifest'])
|
|
121
126
|
for (const [i, payload] of payloads.entries()) {
|
|
122
127
|
const target = join(dir, String(i))
|
|
123
|
-
|
|
128
|
+
wanted.add(String(i))
|
|
129
|
+
changed = writeFileIfChanged(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`) || changed
|
|
124
130
|
manifest.push(`${payload.file}\t${target}`)
|
|
125
131
|
}
|
|
132
|
+
for (const entry of readdirSync(dir)) {
|
|
133
|
+
if (!wanted.has(entry)) { rmSync(join(dir, entry), { recursive: true, force: true }); changed = true }
|
|
134
|
+
}
|
|
126
135
|
const manifestPath = join(dir, 'manifest')
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
changed = writeFileIfChanged(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : '')) || changed
|
|
137
|
+
changed = writeFileIfChanged(rootPath(common), `${runtimeRoot(proj)}\n`) || changed
|
|
129
138
|
// Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
|
|
130
139
|
// the driver selects a payload from the invoking checkout's tree slot.
|
|
131
140
|
const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
|
|
132
141
|
mkdirSync(join(common, 'info'), { recursive: true })
|
|
133
|
-
writeManagedBlock(attributesPath(common), entries, ['# ', ''])
|
|
134
|
-
|
|
142
|
+
changed = writeManagedBlock(attributesPath(common), entries, ['# ', '']) || changed
|
|
143
|
+
const settle = changed ? payloads.map((p) => join(proj, p.file)) : changedFiles
|
|
144
|
+
if (settle.length) settleIndexStat(proj, [...new Set(settle)])
|
|
145
|
+
return changed
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
|
|
@@ -150,7 +161,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
150
161
|
const rel = relative(proj, f)
|
|
151
162
|
try {
|
|
152
163
|
const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
|
|
153
|
-
const filtered = execFileSync(
|
|
164
|
+
const filtered = execFileSync(gitBinary(env), ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
|
|
154
165
|
{ input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
|
|
155
166
|
if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
|
|
156
167
|
} catch { /* best-effort */ }
|
|
@@ -160,24 +171,29 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
160
171
|
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
161
172
|
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
162
173
|
// (evidence blobs), so only OUR two files go, never the dir.
|
|
174
|
+
export function clearContractFilterPayload(proj: string, files: string[] = []): void {
|
|
175
|
+
try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
|
|
176
|
+
settleIndexStat(proj, files)
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
|
|
164
180
|
let common: string
|
|
165
181
|
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
clearContractFilterPayload(proj, files)
|
|
183
|
+
const anotherPayload = final ? false : (() => {
|
|
184
|
+
const rows = git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
185
|
+
const root = runtimeRoot(proj)
|
|
186
|
+
return rows
|
|
187
|
+
.filter((row) => row.startsWith('worktree '))
|
|
188
|
+
.map((row) => row.slice('worktree '.length))
|
|
189
|
+
.some((tree) => existsSync(join(root, 'trees', encodeProject(tree), 'contract-filter', 'manifest')))
|
|
190
|
+
})()
|
|
191
|
+
if (!final && anotherPayload) return
|
|
172
192
|
removeManagedBlock(attributesPath(common), ['# ', ''], true)
|
|
173
193
|
for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
|
|
174
194
|
try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
|
|
175
195
|
}
|
|
176
|
-
for (const path of [shimPath(common),
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function retireLegacyContractBlock(proj: string): void {
|
|
180
|
-
if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
|
|
196
|
+
for (const path of [shimPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
|
|
181
197
|
}
|
|
182
198
|
|
|
183
199
|
// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
|
|
@@ -8,9 +8,16 @@ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
|
8
8
|
// or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
|
|
9
9
|
// queue is only ever filled by an enqueue — which is why no backlog migration exists.
|
|
10
10
|
|
|
11
|
-
export type PendingMessage = {
|
|
11
|
+
export type PendingMessage = {
|
|
12
|
+
mid: string
|
|
13
|
+
text: string
|
|
14
|
+
from: string | null
|
|
15
|
+
dispatch?: { operation: 'merge'; requestDigest: string }
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
|
|
19
|
+
const revokedSenderRoot = (): string => join(runtimeRoot(), '.revoked-senders')
|
|
20
|
+
const revokedSenderPath = (id: string): string => join(revokedSenderRoot(), id)
|
|
14
21
|
|
|
15
22
|
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
16
23
|
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
@@ -53,7 +60,10 @@ function read(id: string): PendingMessage[] {
|
|
|
53
60
|
return raw.filter((m): m is PendingMessage =>
|
|
54
61
|
!!m && typeof m === 'object'
|
|
55
62
|
&& typeof (m as PendingMessage).mid === 'string'
|
|
56
|
-
&& typeof (m as PendingMessage).text === 'string'
|
|
63
|
+
&& typeof (m as PendingMessage).text === 'string'
|
|
64
|
+
&& ((m as PendingMessage).dispatch === undefined
|
|
65
|
+
|| ((m as PendingMessage).dispatch?.operation === 'merge'
|
|
66
|
+
&& typeof (m as PendingMessage).dispatch?.requestDigest === 'string')))
|
|
57
67
|
} catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -68,17 +78,59 @@ function write(id: string, msgs: PendingMessage[]): void {
|
|
|
68
78
|
renameSync(tmp, path)
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
// A closed sender may have left debt in many other sessions' queues. The marker is deliberately outside its
|
|
82
|
+
// own store (which close removes) and is checked under the sender's record lock by dispatch: a close cannot
|
|
83
|
+
// return while an old process can still append, and a later sweep cannot hand over what it sees here.
|
|
84
|
+
export function revokeSenderDelivery(id: string): void {
|
|
85
|
+
mkdirSync(revokedSenderRoot(), { recursive: true })
|
|
86
|
+
writeFileSync(revokedSenderPath(id), `${id}\n`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const senderDeliveryRevoked = (id: string): boolean => existsSync(revokedSenderPath(id))
|
|
90
|
+
|
|
91
|
+
export function pendingSnapshot(id: string): PendingMessage[] { return read(id) }
|
|
92
|
+
|
|
93
|
+
// These two writes require the target's delivery lock. They are the queue half of a larger transaction
|
|
94
|
+
// (currently reparent), which must be able to restore the exact previous debt if a later record write fails.
|
|
95
|
+
export function replacePendingWhileLocked(id: string, msgs: PendingMessage[]): void { write(id, msgs) }
|
|
96
|
+
export function revokePendingFromWhileLocked(id: string, sender: string): number {
|
|
97
|
+
const current = read(id)
|
|
98
|
+
const next = current.filter((msg) => msg.from !== sender)
|
|
99
|
+
write(id, next)
|
|
100
|
+
return current.length - next.length
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
// The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
|
|
72
|
-
// both, and the record is written first, so
|
|
73
|
-
//
|
|
104
|
+
// both, and the record is written first, so delivery is never unrecorded. A keyed dispatch also carries its
|
|
105
|
+
// exact delivery bytes in the timeline receipt, letting its retry restore a queue write lost to a crash.
|
|
74
106
|
export function enqueue(id: string, msg: PendingMessage): void {
|
|
75
107
|
write(id, [...read(id), msg])
|
|
76
108
|
}
|
|
77
109
|
|
|
110
|
+
// Caller holds this target's delivery lock. A retry may reach this after either side of the receipt->queue
|
|
111
|
+
// crash boundary; exact mid identity makes reconstruction idempotent without inspecting message text.
|
|
112
|
+
export function ensurePendingWhileLocked(id: string, msg: PendingMessage): boolean {
|
|
113
|
+
const current = read(id)
|
|
114
|
+
if (current.some((pending) => pending.mid === msg.mid)) return false
|
|
115
|
+
write(id, [...current, msg])
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
export const pendingMessages = (id: string): PendingMessage[] => read(id)
|
|
79
120
|
|
|
80
121
|
export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
|
|
81
122
|
|
|
123
|
+
// Record transitions and queue mutations take locks in the same direction: record locks first, then these
|
|
124
|
+
// target queue locks. A batch reparent needs all child queues held at once so its pointer/watch/debt change
|
|
125
|
+
// either commits together or restores together.
|
|
126
|
+
export async function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index = 0, ids = [...new Set(rawIds)].sort()): Promise<T> {
|
|
127
|
+
if (index >= ids.length) return body()
|
|
128
|
+
const release = await acquire(ids[index], 30_000)
|
|
129
|
+
if (!release) throw new Error(`delivery queue ${ids[index]}: timed out waiting for transaction lock`)
|
|
130
|
+
try { return await withDeliveryLocks(ids, body, index + 1, ids) }
|
|
131
|
+
finally { release() }
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
83
135
|
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
84
136
|
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
@@ -95,6 +147,12 @@ export async function drain(
|
|
|
95
147
|
for (;;) {
|
|
96
148
|
const queued = read(id)
|
|
97
149
|
if (!queued.length) return { delivered, remaining: 0 }
|
|
150
|
+
if (queued[0].from && senderDeliveryRevoked(queued[0].from)) {
|
|
151
|
+
// Closing a sender voids its undelivered output, not the recipient's immutable conversation history.
|
|
152
|
+
// Drop a revoked head and continue so it cannot permanently block the messages behind it.
|
|
153
|
+
write(id, read(id).filter((m) => m.mid !== queued[0].mid))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
98
156
|
let ok = false
|
|
99
157
|
try { ok = await insert(queued[0]) } catch { ok = false }
|
|
100
158
|
if (!ok) return { delivered, remaining: queued.length }
|