spexcode 0.5.9 → 0.6.1
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 +69 -70
- package/spec-cli/src/client.ts +1 -1
- 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 +107 -0
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +12 -10
- package/spec-cli/src/help.ts +9 -14
- package/spec-cli/src/index.ts +10 -2
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/session-cursors.ts +11 -17
- package/spec-cli/src/session-follow.ts +6 -6
- package/spec-cli/src/sessions.ts +224 -38
- package/spec-cli/src/uninstall.ts +2 -1
- 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/mark-active/mark-active.sh +5 -41
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
- 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-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- 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 +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
4
|
+
|
|
5
|
+
// @@@ delivery-queue - what a session still OWES its agent. The log ([[session-timeline]]) is the record and
|
|
6
|
+
// grows forever; this is the debt and is consumed, so it lives in its own small file whose resting state is
|
|
7
|
+
// EMPTY. Nothing here reads the log: an entry carries the text it will hand over, so history could be trimmed
|
|
8
|
+
// or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
|
|
9
|
+
// queue is only ever filled by an enqueue — which is why no backlog migration exists.
|
|
10
|
+
|
|
11
|
+
export type PendingMessage = { mid: string; text: string; from: string | null }
|
|
12
|
+
|
|
13
|
+
const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
|
|
14
|
+
|
|
15
|
+
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
16
|
+
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
17
|
+
// record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
|
|
18
|
+
// and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
|
|
19
|
+
// so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
|
|
20
|
+
const lockRoot = (): string => join(runtimeRoot(), '.delivery-locks')
|
|
21
|
+
const lockPath = (id: string): string => join(lockRoot(), `${id}.lock`)
|
|
22
|
+
|
|
23
|
+
const pause = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
24
|
+
|
|
25
|
+
async function acquire(id: string, timeoutMs: number): Promise<(() => void) | null> {
|
|
26
|
+
mkdirSync(lockRoot(), { recursive: true })
|
|
27
|
+
const path = lockPath(id), deadline = Date.now() + timeoutMs
|
|
28
|
+
for (;;) {
|
|
29
|
+
try {
|
|
30
|
+
const fd = openSync(path, 'wx')
|
|
31
|
+
writeSync(fd, String(process.pid))
|
|
32
|
+
closeSync(fd)
|
|
33
|
+
return () => { try { unlinkSync(path) } catch { /* a liveness reclaim already removed it */ } }
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
36
|
+
let owner = 0
|
|
37
|
+
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
38
|
+
if (owner && owner !== process.pid) {
|
|
39
|
+
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
40
|
+
}
|
|
41
|
+
// A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
|
|
42
|
+
// and the retry sweep will come back. Declining is not a lost message.
|
|
43
|
+
if (Date.now() >= deadline) return null
|
|
44
|
+
await pause(25)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function read(id: string): PendingMessage[] {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(queuePath(id), 'utf8')) as unknown
|
|
52
|
+
if (!Array.isArray(raw)) return []
|
|
53
|
+
return raw.filter((m): m is PendingMessage =>
|
|
54
|
+
!!m && typeof m === 'object'
|
|
55
|
+
&& typeof (m as PendingMessage).mid === 'string'
|
|
56
|
+
&& typeof (m as PendingMessage).text === 'string')
|
|
57
|
+
} catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
|
|
61
|
+
// one existsSync on the sweep's hot path.
|
|
62
|
+
function write(id: string, msgs: PendingMessage[]): void {
|
|
63
|
+
const path = queuePath(id)
|
|
64
|
+
if (!msgs.length) { try { unlinkSync(path) } catch { /* already gone */ } ; return }
|
|
65
|
+
mkdirSync(sessionStoreDir(id), { recursive: true })
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n')
|
|
68
|
+
renameSync(tmp, path)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 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 a crash between them leaves a message visible but undelivered —
|
|
73
|
+
// never delivered but unrecorded.
|
|
74
|
+
export function enqueue(id: string, msg: PendingMessage): void {
|
|
75
|
+
write(id, [...read(id), msg])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const pendingMessages = (id: string): PendingMessage[] => read(id)
|
|
79
|
+
|
|
80
|
+
export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
|
|
81
|
+
|
|
82
|
+
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
83
|
+
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
84
|
+
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
85
|
+
// one. Returns how many were handed over and how many are still owed.
|
|
86
|
+
export async function drain(
|
|
87
|
+
id: string,
|
|
88
|
+
insert: (msg: PendingMessage) => Promise<boolean>,
|
|
89
|
+
timeoutMs = 5_000,
|
|
90
|
+
): Promise<{ delivered: number; remaining: number }> {
|
|
91
|
+
const release = await acquire(id, timeoutMs)
|
|
92
|
+
if (!release) return { delivered: 0, remaining: read(id).length }
|
|
93
|
+
let delivered = 0
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
const queued = read(id)
|
|
97
|
+
if (!queued.length) return { delivered, remaining: 0 }
|
|
98
|
+
let ok = false
|
|
99
|
+
try { ok = await insert(queued[0]) } catch { ok = false }
|
|
100
|
+
if (!ok) return { delivered, remaining: queued.length }
|
|
101
|
+
// Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
|
|
102
|
+
// stale snapshot minus the head would silently drop it.
|
|
103
|
+
write(id, read(id).filter((m) => m.mid !== queued[0].mid))
|
|
104
|
+
delivered++
|
|
105
|
+
}
|
|
106
|
+
} finally { release() }
|
|
107
|
+
}
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
|
|
|
7
7
|
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
|
|
8
8
|
import { loadConfig } from './lint.js'
|
|
9
9
|
import { trackedSourceFiles } from './source-files.js'
|
|
10
|
+
import { gitBinary } from './git.js'
|
|
10
11
|
|
|
11
12
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
12
13
|
// materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
|
|
@@ -14,7 +15,7 @@ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
14
15
|
|
|
15
16
|
// run a git query in `dir`, swallowing git's own stderr — a non-repo returns null (the absence IS the signal).
|
|
16
17
|
function git(dir: string, args: string[]): string | null {
|
|
17
|
-
try { return execFileSync(
|
|
18
|
+
try { return execFileSync(gitBinary(process.env), ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
|
|
18
19
|
catch { return null }
|
|
19
20
|
}
|
|
20
21
|
function repoRoot(dir: string): string | null { return git(dir, ['rev-parse', '--show-toplevel']) }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
export function writeFileIfChanged(path: string, content: string | Uint8Array): boolean {
|
|
4
|
+
const next = Buffer.from(content)
|
|
5
|
+
try {
|
|
6
|
+
if (readFileSync(path).equals(next)) return false
|
|
7
|
+
} catch (error: any) {
|
|
8
|
+
if (error?.code !== 'ENOENT') throw error
|
|
9
|
+
}
|
|
10
|
+
writeFileSync(path, content)
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function copyFileIfChanged(source: string, target: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
if (readFileSync(target).equals(readFileSync(source))) return false
|
|
17
|
+
} catch (error: any) {
|
|
18
|
+
if (error?.code !== 'ENOENT') throw error
|
|
19
|
+
}
|
|
20
|
+
copyFileSync(source, target)
|
|
21
|
+
return true
|
|
22
|
+
}
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
-
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync } from 'node:fs'
|
|
4
|
-
import { join, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
|
|
4
|
+
import { join, isAbsolute, resolve, delimiter } from 'node:path'
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto'
|
|
6
6
|
import { projectRuntimeRoot } from './project-store.js'
|
|
7
7
|
import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
|
|
@@ -15,6 +15,24 @@ const US = '\x1f', RS = '\x1e'
|
|
|
15
15
|
// than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
|
|
16
16
|
const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
|
|
17
17
|
export const BOARD_GIT_CONCURRENCY = 4
|
|
18
|
+
const gitByPath = new Map<string, string>()
|
|
19
|
+
|
|
20
|
+
export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
|
|
21
|
+
const path = env.PATH || ''
|
|
22
|
+
const known = gitByPath.get(path)
|
|
23
|
+
if (known) {
|
|
24
|
+
try { accessSync(known, constants.X_OK); return known } catch {}
|
|
25
|
+
}
|
|
26
|
+
for (const dir of path.split(delimiter)) {
|
|
27
|
+
const candidate = resolve(dir || '.', 'git')
|
|
28
|
+
try {
|
|
29
|
+
accessSync(candidate, constants.X_OK)
|
|
30
|
+
gitByPath.set(path, candidate)
|
|
31
|
+
return candidate
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
throw new Error('git executable not found on PATH')
|
|
35
|
+
}
|
|
18
36
|
type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
|
|
19
37
|
type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
|
|
20
38
|
const gitBuild = new AsyncLocalStorage<GitBuildContext>()
|
|
@@ -115,7 +133,7 @@ export function git(args: string[]): string {
|
|
|
115
133
|
const env = { ...process.env }
|
|
116
134
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
117
135
|
try {
|
|
118
|
-
return execFileSync(
|
|
136
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
|
|
119
137
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
120
138
|
}
|
|
121
139
|
|
|
@@ -123,7 +141,7 @@ function gitBuffer(args: string[], input?: string): Buffer {
|
|
|
123
141
|
const env = { ...process.env }
|
|
124
142
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
125
143
|
try {
|
|
126
|
-
return execFileSync(
|
|
144
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
127
145
|
input,
|
|
128
146
|
env,
|
|
129
147
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -267,7 +285,7 @@ const GIT_MAX_BUFFER = 1 << 24
|
|
|
267
285
|
function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
|
|
268
286
|
return new Promise((resolve, reject) => {
|
|
269
287
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
270
|
-
const child = spawn(
|
|
288
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
271
289
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
272
290
|
let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
|
|
273
291
|
let spawnError: Error | null = null
|
|
@@ -338,7 +356,7 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
|
|
|
338
356
|
function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
|
|
339
357
|
return new Promise((resolve, reject) => {
|
|
340
358
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
341
|
-
const child = spawn(
|
|
359
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
342
360
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
343
361
|
let settled = false, aborted = false, timedOut = false
|
|
344
362
|
const killTree = () => {
|
|
@@ -826,8 +844,12 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
|
|
|
826
844
|
return value as IdentityRawRecord[]
|
|
827
845
|
}
|
|
828
846
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
829
|
-
export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
847
|
+
export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
830
848
|
const env = { ...process.env }
|
|
849
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
850
|
+
if (value === undefined) delete env[key]
|
|
851
|
+
else env[key] = value
|
|
852
|
+
}
|
|
831
853
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
832
854
|
if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
|
|
833
855
|
const context = inheritedContext()
|
|
@@ -2091,7 +2113,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
|
|
|
2091
2113
|
return new Promise((resolve) => {
|
|
2092
2114
|
const env = { ...process.env }
|
|
2093
2115
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
2094
|
-
execFile(
|
|
2116
|
+
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
|
|
2095
2117
|
{ encoding: 'utf8', env, maxBuffer: 1 << 24 },
|
|
2096
2118
|
// execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
|
|
2097
2119
|
// errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming'
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
|
|
4
|
-
import { join, dirname, relative, resolve } from 'node:path'
|
|
5
|
-
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
-
import { hotSignature, warmSignature, listSessions } from './sessions.js'
|
|
4
|
+
import { join, dirname, relative, resolve, basename } from 'node:path'
|
|
5
|
+
import { sessionsRoot, gitCommonDir, sessionBranchIndex, mainBranch } from './layout.js'
|
|
6
|
+
import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js'
|
|
7
7
|
import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js'
|
|
8
8
|
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
9
|
import {
|
|
@@ -217,6 +217,16 @@ export const addPendingGraphChange = (pending: PendingGraphChanges, scope: Scope
|
|
|
217
217
|
sessions: pending.sessions || scope === 'sessions',
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
+
// Git creates the registry entry before the session transaction publishes its durable record. A registry
|
|
221
|
+
// event for that private candidate must not start a full board build while the `git worktree add` child is
|
|
222
|
+
// still running; the create route flushes the deferred event after publication (or cleanup).
|
|
223
|
+
export function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean {
|
|
224
|
+
const entry = relativePath.split(/[\\/]+/).filter(Boolean)[0] ?? ''
|
|
225
|
+
if (!entry) return false
|
|
226
|
+
for (const path of candidatePaths) if (basename(resolve(path)) === entry) return true
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
220
230
|
// under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
|
|
221
231
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
222
232
|
function traceLatency(stage: 'sessions-signal' | 'session-projection-complete' | 'broadcast', detail: Record<string, unknown> = {}): void {
|
|
@@ -466,7 +476,7 @@ function ensureWatcher(root: string): void {
|
|
|
466
476
|
root,
|
|
467
477
|
source: 'store',
|
|
468
478
|
scope: 'sessions',
|
|
469
|
-
onInput: () => fireChanged('sessions'),
|
|
479
|
+
onInput: () => { dropBranchIndex(); fireChanged('sessions') }, // a created/renamed session may own a new branch
|
|
470
480
|
onFailure: (error) => {
|
|
471
481
|
if (storeWatcher === registry) storeWatcher = null
|
|
472
482
|
noteSourceFailure('store', error)
|
|
@@ -492,9 +502,34 @@ type RegistryGroup = {
|
|
|
492
502
|
let refsWatchers: RegistryGroup | null = null
|
|
493
503
|
const REFS_OBSERVER = 'graph:refs'
|
|
494
504
|
|
|
505
|
+
// @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
|
|
506
|
+
// every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
|
|
507
|
+
// and a bot that commits continuously that means nothing is ever warm: observed on z-code as an input
|
|
508
|
+
// generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
|
|
509
|
+
// tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
|
|
510
|
+
// `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
|
|
511
|
+
// the index is read from the session store, so it is only ever stale when that store moved — and the store
|
|
512
|
+
// has its own watcher, which drops it. A ref burst therefore costs one map lookup, not 76 record reads.
|
|
513
|
+
let branchIndexMemo: Map<string, string> | null = null
|
|
514
|
+
function branchIndex(): Map<string, string> {
|
|
515
|
+
return (branchIndexMemo ??= sessionBranchIndex())
|
|
516
|
+
}
|
|
517
|
+
function dropBranchIndex(): void { branchIndexMemo = null }
|
|
518
|
+
|
|
519
|
+
export function evalTargetForRef(ref: string | undefined, base: string, branches: Map<string, string>): EvalTarget | undefined {
|
|
520
|
+
if (ref === undefined) return 'all' // an unnamed movement must assume the worst
|
|
521
|
+
const rel = ref.replace(/\\/g, '/')
|
|
522
|
+
if (!rel.startsWith('heads/')) return undefined // tags and remotes feed no session fingerprint
|
|
523
|
+
const branch = rel.slice('heads/'.length)
|
|
524
|
+
if (!branch) return 'all'
|
|
525
|
+
if (branch === base) return 'all' // every merge-base may have moved
|
|
526
|
+
const id = branches.get(branch)
|
|
527
|
+
return id ? { id } : undefined // a branch no session owns moves no session's evaluation
|
|
528
|
+
}
|
|
529
|
+
|
|
495
530
|
export function watchSessionEvalRefs(
|
|
496
531
|
common: string,
|
|
497
|
-
onInput: () => void,
|
|
532
|
+
onInput: (ref?: string) => void,
|
|
498
533
|
onFailure: (error: Error) => void,
|
|
499
534
|
): RegistryGroup {
|
|
500
535
|
let attached: TreeWatcherRegistry[] = []
|
|
@@ -517,7 +552,7 @@ export function watchSessionEvalRefs(
|
|
|
517
552
|
root: join(common, 'refs'),
|
|
518
553
|
source: 'refs',
|
|
519
554
|
scope: 'full',
|
|
520
|
-
onInput: () => onInput(),
|
|
555
|
+
onInput: (_event, rel) => onInput(rel),
|
|
521
556
|
onFailure: fail,
|
|
522
557
|
})
|
|
523
558
|
attached.push(refs)
|
|
@@ -528,7 +563,7 @@ export function watchSessionEvalRefs(
|
|
|
528
563
|
source: 'refs-common',
|
|
529
564
|
scope: 'full',
|
|
530
565
|
recursive: false,
|
|
531
|
-
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() },
|
|
566
|
+
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() }, // names nothing -> 'all'
|
|
532
567
|
onFailure: fail,
|
|
533
568
|
})
|
|
534
569
|
attached.push(commonFiles)
|
|
@@ -553,7 +588,7 @@ function ensureRefsWatcher(common = activeCommonRoot): void {
|
|
|
553
588
|
}
|
|
554
589
|
if (!mayAttach('refs')) return
|
|
555
590
|
try {
|
|
556
|
-
refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full',
|
|
591
|
+
refsWatchers = watchSessionEvalRefs(common, (ref) => fireChanged('full', evalTargetForRef(ref, mainBranch(), branchIndex())), refsWatcherFailed)
|
|
557
592
|
noteSourceHealthy('refs')
|
|
558
593
|
if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
|
|
559
594
|
} catch (error) {
|
|
@@ -745,10 +780,37 @@ function reconcileWorktrees(forceSessionId?: string): Promise<void> {
|
|
|
745
780
|
return flight
|
|
746
781
|
}
|
|
747
782
|
const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
|
|
783
|
+
let deferredRegistryChange = false
|
|
784
|
+
let deferredRegistryTimer: ReturnType<typeof setTimeout> | null = null
|
|
785
|
+
|
|
786
|
+
function flushDeferredRegistryChange(): void {
|
|
787
|
+
if (!deferredRegistryChange) return
|
|
788
|
+
deferredRegistryChange = false
|
|
789
|
+
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
790
|
+
void reconcileWorktrees()
|
|
791
|
+
fireChanged('full', 'all')
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// A crashed or disconnected create request must not leave the registry event deferred forever. The normal
|
|
795
|
+
// route flush is immediate; this bounded fallback preserves the watcher contract if the process never reaches
|
|
796
|
+
// its response boundary.
|
|
797
|
+
function deferRegistryChange(): void {
|
|
798
|
+
deferredRegistryChange = true
|
|
799
|
+
if (deferredRegistryTimer) return
|
|
800
|
+
deferredRegistryTimer = setTimeout(() => {
|
|
801
|
+
deferredRegistryTimer = null
|
|
802
|
+
flushDeferredRegistryChange()
|
|
803
|
+
}, 10_000)
|
|
804
|
+
deferredRegistryTimer.unref?.()
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function flushDeferredWorktreeRegistryChange(): void {
|
|
808
|
+
flushDeferredRegistryChange()
|
|
809
|
+
}
|
|
748
810
|
|
|
749
811
|
export function watchSessionEvalRegistry(
|
|
750
812
|
dir: string,
|
|
751
|
-
onInput: () => void,
|
|
813
|
+
onInput: (event: 'rename' | 'change', relativePath: string) => void,
|
|
752
814
|
onFailure: (error: Error) => void,
|
|
753
815
|
): TreeWatcherRegistry {
|
|
754
816
|
let ready = false
|
|
@@ -758,7 +820,7 @@ export function watchSessionEvalRegistry(
|
|
|
758
820
|
source: 'worktree-registry',
|
|
759
821
|
scope: 'full',
|
|
760
822
|
recursive: false,
|
|
761
|
-
onInput: () => onInput(),
|
|
823
|
+
onInput: (_event, relativePath) => onInput(_event, relativePath),
|
|
762
824
|
onFailure: (error) => {
|
|
763
825
|
if (ready) onFailure(error)
|
|
764
826
|
else attachError = error
|
|
@@ -807,7 +869,11 @@ async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
|
|
|
807
869
|
catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
808
870
|
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
809
871
|
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
810
|
-
registryWatcher = watchSessionEvalRegistry(dir, () => {
|
|
872
|
+
registryWatcher = watchSessionEvalRegistry(dir, (_event, relativePath) => {
|
|
873
|
+
if (isSessionCreateCandidateRegistryEvent(relativePath, pendingSessionCreateWorktreePaths())) {
|
|
874
|
+
deferRegistryChange()
|
|
875
|
+
return
|
|
876
|
+
}
|
|
811
877
|
void reconcileWorktrees()
|
|
812
878
|
fireChanged('full', 'all')
|
|
813
879
|
}, registryWatcherFailed)
|
|
@@ -841,6 +907,8 @@ export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<
|
|
|
841
907
|
export function closeBoardFileWatchers(): void {
|
|
842
908
|
watcherEra++
|
|
843
909
|
if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
|
|
910
|
+
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
911
|
+
deferredRegistryChange = false
|
|
844
912
|
heldSources.clear()
|
|
845
913
|
repairStep = 0
|
|
846
914
|
repairing = false
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { git } from './git.js'
|
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
|
|
21
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
21
22
|
|
|
22
23
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
23
24
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -282,7 +283,7 @@ export interface Harness {
|
|
|
282
283
|
// write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
|
|
283
284
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
284
285
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
285
|
-
// `ok=false` leaves the
|
|
286
|
+
// `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
|
|
286
287
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
287
288
|
// Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
|
|
288
289
|
// transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
|
|
@@ -702,10 +703,12 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
702
703
|
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
703
704
|
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
704
705
|
// The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
|
|
705
|
-
// resume resolves its existing session/thread binding
|
|
706
|
-
// replacement
|
|
706
|
+
// resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
|
|
707
|
+
// replacement. Both spellings carry the server command because either may be the launch that has to start a
|
|
708
|
+
// root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
|
|
709
|
+
// on-disk rollout. It prints only shell assignments for the exact proven endpoint.
|
|
707
710
|
'if [ "$1" = "--resume" ]; then',
|
|
708
|
-
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
|
|
711
|
+
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
|
|
709
712
|
'else',
|
|
710
713
|
` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
|
|
711
714
|
'fi',
|
|
@@ -1797,17 +1800,16 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
|
|
|
1797
1800
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
1798
1801
|
// comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
|
|
1799
1802
|
// (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
|
|
1800
|
-
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']):
|
|
1803
|
+
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
|
|
1801
1804
|
const [open, close] = comment
|
|
1802
1805
|
const START = `${open}spexcode:start${close}`
|
|
1803
1806
|
const END = `${open}spexcode:end${close}`
|
|
1804
1807
|
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1805
1808
|
const block = `${START}\n${body}\n${END}`
|
|
1806
|
-
|
|
1809
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
1807
1810
|
const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
writeFileSync(file, cur)
|
|
1811
|
+
const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
|
|
1812
|
+
return writeFileIfChanged(file, next)
|
|
1811
1813
|
}
|
|
1812
1814
|
|
|
1813
1815
|
// the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
|
|
@@ -1916,7 +1918,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
|
|
|
1916
1918
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
|
|
1917
1919
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
1918
1920
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
1919
|
-
|
|
1921
|
+
writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
1920
1922
|
return file
|
|
1921
1923
|
}
|
|
1922
1924
|
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -44,10 +44,10 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
|
|
|
44
44
|
ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
|
|
45
45
|
'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
|
|
46
46
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
47
|
-
watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
|
|
48
|
-
'
|
|
47
|
+
watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
|
|
48
|
+
'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
|
|
49
49
|
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
50
|
-
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own
|
|
50
|
+
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
|
|
51
51
|
the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
|
|
52
52
|
status into an actionable one (stdout = the observed path, e.g. working→review; read the LAST
|
|
53
53
|
token as the status reached), or a message arriving for you (stdout = message). The arrival
|
|
@@ -79,7 +79,7 @@ you are running in, mid-turn. Your own ending is a declaration: \`done --propose
|
|
|
79
79
|
done: ['spex session done --propose merge|nothing|close [--note T]',
|
|
80
80
|
'`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` declares done: committed work, but no merge proposal. `close` declares close-pending: PROPOSE discarding this worktree — the human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
|
|
81
81
|
park: ['spex session park --note <what-you-await>',
|
|
82
|
-
'Declare parked only when a real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
82
|
+
'Declare parked only when a managed watch delivery or real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
83
83
|
ask: ['spex session ask --note <your-question>',
|
|
84
84
|
'Declare asking when the session needs a human reply or direction. It resumes only when the human replies; a background wake-up is parked instead.'],
|
|
85
85
|
attach: ['spex session attach <SEL>', `Attaches the current terminal to the worker's tmux (detach: C-b d) and blocks until detached.
|
|
@@ -273,7 +273,7 @@ edit the spec instead — same commit as the code.`,
|
|
|
273
273
|
see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
|
|
274
274
|
},
|
|
275
275
|
eval: {
|
|
276
|
-
line: 'eval <verb> the measurement system: add · ls · scenario ls/write ·
|
|
276
|
+
line: 'eval <verb> the measurement system: add · ls · scenario ls/write · lint · ok · retract · clean',
|
|
277
277
|
body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
|
|
278
278
|
[--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
|
|
279
279
|
spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
|
|
@@ -281,7 +281,6 @@ edit the spec instead — same commit as the code.`,
|
|
|
281
281
|
spex eval ls --session <SEL> --export [--open | --out <path>]
|
|
282
282
|
spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; JSON = canonical index
|
|
283
283
|
spex eval scenario write --mutation <json> < eval.md propose one canonical metadata mutation
|
|
284
|
-
spex eval matrix <launcher> [--node <id>] [--rows k1,k2] the harness live-behavior matrix
|
|
285
284
|
spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
|
|
286
285
|
spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest measurement
|
|
287
286
|
spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
|
|
@@ -305,12 +304,6 @@ scenario write — the fixed-tree declaration writer for an external measurement
|
|
|
305
304
|
eval.md, --mutation is one closed JSON insert/delete request for one scenario's test metadata, and stdout is only
|
|
306
305
|
the proposed eval.md bytes. It reads no worktree or runner and fails without stdout on malformed or ambiguous input.
|
|
307
306
|
|
|
308
|
-
matrix — run the eight-row harness live-behavior matrix against a REAL dispatched session of the named
|
|
309
|
-
launcher (the harness-adapter acceptance rule, defined once in spec-eval/src/matrix.ts): it syncs the
|
|
310
|
-
rows into the \`<harness>-harness\` node's eval.md scenarios, drives one worker through undeclared-stop ·
|
|
311
|
-
pretooluse-block · ask-note · deliver-steer · resume · liveness · commit-gate · close-residue, and files
|
|
312
|
-
a per-row measurement with its evidence transcript. A new harness needs only its launcher + spec node.
|
|
313
|
-
|
|
314
307
|
lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
|
|
315
308
|
stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source with no eval.md
|
|
316
309
|
(eval-coverage — the same name and shape as spec lint's coverage, one rule per layer) · over-owned
|
|
@@ -439,10 +432,12 @@ export function commandHelp(name: string, verb?: string): string | null {
|
|
|
439
432
|
return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`
|
|
440
433
|
}
|
|
441
434
|
|
|
442
|
-
export function sessionLaunchReceipt(id: string): string {
|
|
435
|
+
export function sessionLaunchReceipt(id: string, managedWatch = false): string {
|
|
443
436
|
return `spex: launched session ${id}
|
|
444
437
|
current result: the session JSON is on stdout now; \`spex session ls ${id}\` is the later one-shot snapshot
|
|
445
|
-
next lifecycle change:
|
|
438
|
+
next lifecycle change: ${managedWatch
|
|
439
|
+
? `managed watch registered — this parent receives ${id}'s state changes through its normal send queue; \`spex session watch cancel ${id}\` ends it`
|
|
440
|
+
: `background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` registers send-backed delivery when the caller is governed`}; \`spex session watch stream ${id}\` NEVER EXITS
|
|
446
441
|
response channel: \`spex session send ${id} "<msg>"\`; \`send --keys\` is an UNSTABLE LAST RESORT after a plain send cannot land`
|
|
447
442
|
}
|
|
448
443
|
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -15,10 +15,10 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
15
15
|
import { summarizeLoopIn } from './mentions.js'
|
|
16
16
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
17
17
|
import { getBoardJson } from './graphCache.js'
|
|
18
|
-
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
18
|
+
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js'
|
|
19
19
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
20
20
|
import { cockpitReview } from './cockpit.js'
|
|
21
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
21
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
22
22
|
import { readTimeline } from './session-timeline.js'
|
|
23
23
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
24
24
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
@@ -455,12 +455,19 @@ app.post('/api/sessions', async (c) => {
|
|
|
455
455
|
try {
|
|
456
456
|
const body = await c.req.json().catch(() => null)
|
|
457
457
|
const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
|
|
458
|
+
// The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
|
|
459
|
+
// wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
|
|
460
|
+
if (result.status === 201) notifyBoardChanged('sessions')
|
|
461
|
+
// A candidate registry event is intentionally held while Git creates the private worktree. Once the
|
|
462
|
+
// transaction has published or cleaned up its record, release the one deferred full refresh.
|
|
463
|
+
flushDeferredWorktreeRegistryChange()
|
|
458
464
|
if (result.status === 201) {
|
|
459
465
|
c.header('Idempotency-Key', requestKey)
|
|
460
466
|
return c.json(result.session, 201)
|
|
461
467
|
}
|
|
462
468
|
return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
|
|
463
469
|
} finally {
|
|
470
|
+
flushDeferredWorktreeRegistryChange()
|
|
464
471
|
rawSignal.removeEventListener('abort', cancelFromRequest)
|
|
465
472
|
outgoing?.off('close', cancel)
|
|
466
473
|
}
|
|
@@ -700,6 +707,7 @@ injectWebSocket(server)
|
|
|
700
707
|
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
701
708
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
702
709
|
superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
710
|
+
superviseDelivery() // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
|
|
703
711
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
704
712
|
|
|
705
713
|
let graphWatchersClosed = false
|