spexcode 0.5.4 → 0.5.5
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/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +6 -5
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/harness.ts +127 -8
- package/spec-cli/src/index.ts +2 -1
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/sessions.ts +113 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,6 +43,16 @@ proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
|
43
43
|
rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
44
44
|
slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
|
|
45
45
|
|
|
46
|
+
# A project transport can outlive the tree that installed it. The current tree's last successful materialize
|
|
47
|
+
# is the authority for whether its events are active. Before the v1 marker, an absent allowlist is the legacy
|
|
48
|
+
# shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
|
|
49
|
+
allowed="$slot/harnesses"
|
|
50
|
+
if [ -f "$allowed" ]; then
|
|
51
|
+
grep -Fxq "$harness" "$allowed" || exit 0
|
|
52
|
+
elif [ -f "$rt/harness-selection-v1" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
46
56
|
# --- dispatch ---------------------------------------------------------------------------------------------
|
|
47
57
|
if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
|
|
48
58
|
manifest="$SPEX_HOOK_MANIFEST"
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1130,10 +1130,11 @@ if (cmd === 'serve') {
|
|
|
1130
1130
|
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
1131
1131
|
console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
|
|
1132
1132
|
} else if (sub === 'session-fail') {
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1133
|
+
// StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
|
|
1134
|
+
// stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
|
|
1135
|
+
const { s, sess, mark } = await stateKit()
|
|
1136
|
+
const failed = mark(() => s.markTurnFailure(sess, 'claude turn failed'))
|
|
1137
|
+
console.log(failed.ok ? 'marked error' : failed.reason ?? 'noop (session is not live active)')
|
|
1137
1138
|
} else if (sub === 'session-turn-fail') {
|
|
1138
1139
|
// Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
|
|
1139
1140
|
// that landed before teardown wins, so a late child close can never erase an agent-authored state.
|
|
@@ -1143,7 +1144,7 @@ if (cmd === 'serve') {
|
|
|
1143
1144
|
process.exit(2)
|
|
1144
1145
|
}
|
|
1145
1146
|
const { markHeadlessTurnFailure } = await import('./sessions.js')
|
|
1146
|
-
console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (
|
|
1147
|
+
console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (session is not live active)')
|
|
1147
1148
|
} else if (sub === 'session-idle') {
|
|
1148
1149
|
// the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
|
|
1149
1150
|
// at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
4
|
import { git } from './git.js'
|
|
5
5
|
import { writeManagedBlock, removeManagedBlock } from './harness.js'
|
|
6
|
+
import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
|
|
6
7
|
|
|
7
8
|
// @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
|
|
8
9
|
// CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
|
|
@@ -34,30 +35,54 @@ function commonDirOf(proj: string): string {
|
|
|
34
35
|
const filterDir = (common: string) => join(common, 'spexcode')
|
|
35
36
|
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
|
|
36
37
|
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
|
|
38
|
+
const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
|
|
39
|
+
const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
|
|
40
|
+
const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
|
|
37
41
|
const attributesPath = (common: string) => join(common, 'info', 'attributes')
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
44
|
+
.filter((row) => row.startsWith('worktree ')).map((row) =>
|
|
45
|
+
join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
|
|
46
|
+
const legacyFilterTree = (proj: string) => registeredSlots(proj)
|
|
47
|
+
.some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
|
|
48
|
+
|
|
49
|
+
export type ContractFilterPayload = { file: string; content: string }
|
|
50
|
+
export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
|
|
51
|
+
|
|
52
|
+
// The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
|
|
53
|
+
// resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
|
|
41
54
|
// clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
|
|
42
|
-
// smudge: clean first (
|
|
43
|
-
// blank line + START + the contract-block file's content + END.
|
|
55
|
+
// smudge: clean first (a block already in the index can never double-inject), then append the tree payload.
|
|
44
56
|
// Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
|
|
45
57
|
// file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
|
|
46
58
|
const SHIM = `#!/usr/bin/env bash
|
|
47
|
-
# spexcode
|
|
48
|
-
#
|
|
59
|
+
# spexcode managed-text filter (generated by spex materialize; see [[content-filter]]).
|
|
60
|
+
# One common driver selects the payload belonging to the Git checkout that invoked it.
|
|
49
61
|
set -u
|
|
50
|
-
mode="\${1:?usage: contract-filter.sh smudge|clean}"
|
|
62
|
+
mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
|
|
63
|
+
path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
|
|
51
64
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
52
|
-
|
|
65
|
+
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
|
|
66
|
+
[ -n "$binding" ] || { cat; exit 0; }
|
|
67
|
+
start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
|
|
68
|
+
end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
|
|
69
|
+
top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
70
|
+
root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
|
|
71
|
+
key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
|
|
72
|
+
manifest="$root/trees/$key/contract-filter/manifest"
|
|
73
|
+
payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
|
|
74
|
+
# A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
|
|
75
|
+
# per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
|
|
76
|
+
marker="$root/trees/$key/contract-filter-v2"
|
|
77
|
+
if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
|
|
53
78
|
strip() {
|
|
54
|
-
awk 'BEGIN { n = 0 }
|
|
79
|
+
awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
55
80
|
{ lines[n++] = $0 }
|
|
56
81
|
END {
|
|
57
82
|
s = -1; e = -1
|
|
58
83
|
for (i = 0; i < n; i++) {
|
|
59
|
-
if (lines[i] ==
|
|
60
|
-
if (lines[i] ==
|
|
84
|
+
if (lines[i] == sline && s < 0) s = i
|
|
85
|
+
if (lines[i] == eline && s >= 0 && e < 0) e = i
|
|
61
86
|
}
|
|
62
87
|
if (s >= 0 && e >= s) {
|
|
63
88
|
a = s; while (a > 0 && lines[a-1] == "") a--
|
|
@@ -66,24 +91,22 @@ strip() {
|
|
|
66
91
|
for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
|
|
67
92
|
n = j
|
|
68
93
|
}
|
|
69
|
-
# NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
|
|
70
|
-
# blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
|
|
71
94
|
for (i = 0; i < n; i++) print lines[i]
|
|
72
95
|
}'
|
|
73
96
|
}
|
|
74
97
|
case "$mode" in
|
|
75
98
|
clean) strip ;;
|
|
76
99
|
smudge)
|
|
77
|
-
if [ ! -r "$
|
|
78
|
-
strip | awk -v b="$
|
|
100
|
+
if [ ! -r "$payload" ]; then strip; exit 0; fi
|
|
101
|
+
strip | awk -v b="$payload" -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
79
102
|
{ lines[n++] = $0 }
|
|
80
103
|
END {
|
|
81
|
-
while (n > 0 && lines[n-1] == "") n--
|
|
104
|
+
while (n > 0 && lines[n-1] == "") n--
|
|
82
105
|
for (i = 0; i < n; i++) print lines[i]
|
|
83
106
|
if (n > 0) print ""
|
|
84
|
-
print
|
|
107
|
+
print sline
|
|
85
108
|
while ((getline l < b) > 0) print l
|
|
86
|
-
print
|
|
109
|
+
print eline
|
|
87
110
|
}' ;;
|
|
88
111
|
*) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
|
|
89
112
|
esac
|
|
@@ -92,25 +115,37 @@ esac
|
|
|
92
115
|
// edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
|
|
93
116
|
// unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
|
|
94
117
|
const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
|
|
95
|
-
`sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
|
|
118
|
+
`sh -c 'test -r "$0" && exec bash "$0" ${mode} "$1" || exec cat' '${shim.replace(/'/g, `'\\''`)}' %f`
|
|
96
119
|
|
|
97
120
|
// plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
|
|
98
121
|
// pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
|
|
99
122
|
// binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
|
|
100
123
|
// block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
|
|
101
|
-
export function plantContractFilter(proj: string,
|
|
124
|
+
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
|
|
102
125
|
const common = commonDirOf(proj)
|
|
103
126
|
mkdirSync(filterDir(common), { recursive: true })
|
|
104
|
-
writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the materialize
|
|
105
127
|
writeFileSync(shimPath(common), SHIM)
|
|
106
128
|
chmodSync(shimPath(common), 0o755)
|
|
107
129
|
git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
|
|
108
130
|
git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
|
|
109
|
-
|
|
110
|
-
const
|
|
131
|
+
writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
|
|
132
|
+
const dir = treeFilterDir(proj)
|
|
133
|
+
rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
|
|
134
|
+
const manifest: string[] = []
|
|
135
|
+
for (const [i, payload] of payloads.entries()) {
|
|
136
|
+
const target = join(dir, String(i))
|
|
137
|
+
writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
|
|
138
|
+
manifest.push(`${payload.file}\t${target}`)
|
|
139
|
+
}
|
|
140
|
+
const manifestPath = join(dir, 'manifest')
|
|
141
|
+
writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
|
|
142
|
+
writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
|
|
143
|
+
// Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
|
|
144
|
+
// the driver selects a payload from the invoking checkout's tree slot.
|
|
145
|
+
const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
|
|
111
146
|
mkdirSync(join(common, 'info'), { recursive: true })
|
|
112
147
|
writeManagedBlock(attributesPath(common), entries, ['# ', ''])
|
|
113
|
-
settleIndexStat(proj,
|
|
148
|
+
settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
|
|
114
149
|
}
|
|
115
150
|
|
|
116
151
|
// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
|
|
@@ -139,15 +174,24 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
139
174
|
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
140
175
|
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
141
176
|
// (evidence blobs), so only OUR two files go, never the dir.
|
|
142
|
-
export function removeContractFilter(proj: string): void {
|
|
177
|
+
export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
|
|
143
178
|
let common: string
|
|
144
179
|
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
|
180
|
+
try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
|
|
181
|
+
settleIndexStat(proj, files)
|
|
182
|
+
const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
|
|
183
|
+
const legacyTree = legacyFilterTree(proj)
|
|
184
|
+
if (!legacyTree) rmSync(blockPath(common), { force: true })
|
|
185
|
+
if (!final && (anotherPayload || legacyTree)) return
|
|
145
186
|
removeManagedBlock(attributesPath(common), ['# ', ''], true)
|
|
146
187
|
for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
|
|
147
|
-
try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
|
|
188
|
+
try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
|
|
148
189
|
}
|
|
149
|
-
|
|
150
|
-
|
|
190
|
+
for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function retireLegacyContractBlock(proj: string): void {
|
|
194
|
+
if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
|
|
151
195
|
}
|
|
152
196
|
|
|
153
197
|
// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type HarnessLaunchReadinessFence = {
|
|
|
37
37
|
readonly proof: Readonly<Record<string, unknown>>
|
|
38
38
|
validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
|
|
39
39
|
}
|
|
40
|
+
export type TurnFailure = { message: string; completedAt: number | null }
|
|
41
|
+
export type FailureSubscription = { close(): void; readonly closed: Promise<string | null> }
|
|
40
42
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
41
43
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
42
44
|
// one whole-box pid→(ppid, comm) table (a single `ps` spawn).
|
|
@@ -132,6 +134,8 @@ export async function adapterLoadedReferenceState(
|
|
|
132
134
|
|
|
133
135
|
export interface Harness {
|
|
134
136
|
readonly id: HarnessId
|
|
137
|
+
// the id baked into the materialized shim. Headless variants reuse their native family's shim.
|
|
138
|
+
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
135
139
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
136
140
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
137
141
|
readonly headless: boolean
|
|
@@ -192,6 +196,9 @@ export interface Harness {
|
|
|
192
196
|
// --- materialize: shim + contract + trust ([[harness-delivery]]) ---
|
|
193
197
|
// the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
|
|
194
198
|
shimFile(proj: string): string
|
|
199
|
+
// whether that shim belongs to one checkout or the whole project. This is adapter placement data: Codex
|
|
200
|
+
// reads one root-checkout hook file for every linked tree; the other harnesses discover their tree-local file.
|
|
201
|
+
shimScope: 'tree' | 'project'
|
|
195
202
|
// a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
|
|
196
203
|
// config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
|
|
197
204
|
// only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
|
|
@@ -267,6 +274,9 @@ export interface Harness {
|
|
|
267
274
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
268
275
|
// Returns ok=false with a reason that propagates to the API.
|
|
269
276
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
277
|
+
// Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
|
|
278
|
+
// transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
|
|
279
|
+
observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription
|
|
270
280
|
// Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
|
|
271
281
|
// without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
|
|
272
282
|
interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
|
|
@@ -304,7 +314,7 @@ export interface Harness {
|
|
|
304
314
|
// skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
|
|
305
315
|
// data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
|
|
306
316
|
// `harnesses` prunes that harness's products on the next re-materialize.
|
|
307
|
-
clean(proj: string, arts: HarnessArtifacts): void
|
|
317
|
+
clean(proj: string, arts: HarnessArtifacts, preserveProject?: boolean): void
|
|
308
318
|
// the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
|
|
309
319
|
// Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
|
|
310
320
|
removeTrust(proj: string): void
|
|
@@ -816,6 +826,106 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
|
|
|
816
826
|
const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
|
|
817
827
|
const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
|
|
818
828
|
|
|
829
|
+
// Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
|
|
830
|
+
// with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
|
|
831
|
+
// this observer also survives backend replacement; a thread already in systemError is reconciled from its
|
|
832
|
+
// latest turn before later live notifications take over.
|
|
833
|
+
export function codexTurnFailureObserver(
|
|
834
|
+
rec: HarnessDeliveryRecord,
|
|
835
|
+
onFailure: (failure: TurnFailure) => void,
|
|
836
|
+
): FailureSubscription {
|
|
837
|
+
const threadId = rec.harnessSessionId
|
|
838
|
+
if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
|
|
839
|
+
const sock = codexAppServerSock(rec.runtimeDir || runtimeRoot())
|
|
840
|
+
const conn: Socket = createConnection(sock)
|
|
841
|
+
const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
842
|
+
let upgraded = false, settled = false
|
|
843
|
+
let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
|
|
844
|
+
let resolveClosed!: (reason: string | null) => void
|
|
845
|
+
const closed = new Promise<string | null>((resolve) => { resolveClosed = resolve })
|
|
846
|
+
const cancelReconciliation = () => {
|
|
847
|
+
if (!reconciliationTimer) return
|
|
848
|
+
clearTimeout(reconciliationTimer)
|
|
849
|
+
reconciliationTimer = null
|
|
850
|
+
}
|
|
851
|
+
const finish = (reason: string | null) => {
|
|
852
|
+
if (settled) return
|
|
853
|
+
settled = true
|
|
854
|
+
clearTimeout(timer)
|
|
855
|
+
cancelReconciliation()
|
|
856
|
+
try { conn.destroy() } catch {}
|
|
857
|
+
resolveClosed(reason)
|
|
858
|
+
}
|
|
859
|
+
const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000)
|
|
860
|
+
timer.unref?.()
|
|
861
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
862
|
+
const report = (turn: unknown, fallbackMessage?: string) => {
|
|
863
|
+
const value = turn as { status?: unknown; completedAt?: unknown; error?: { message?: unknown } | null }
|
|
864
|
+
if (value?.status !== 'failed' && !fallbackMessage) return
|
|
865
|
+
const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : ''
|
|
866
|
+
onFailure({
|
|
867
|
+
message: nativeMessage || fallbackMessage || 'Codex turn failed',
|
|
868
|
+
completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`))
|
|
872
|
+
conn.on('close', () => finish('Codex turn observer connection closed'))
|
|
873
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
874
|
+
const handle = (json: string) => {
|
|
875
|
+
let message: JsonRpc
|
|
876
|
+
try { message = JSON.parse(json) } catch { return }
|
|
877
|
+
if (message.error) return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`)
|
|
878
|
+
if (message.id === 1 && message.result) {
|
|
879
|
+
send({ method: 'initialized', params: {} })
|
|
880
|
+
return send({
|
|
881
|
+
id: 2,
|
|
882
|
+
method: 'thread/resume',
|
|
883
|
+
params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
|
|
884
|
+
})
|
|
885
|
+
}
|
|
886
|
+
if (message.id === 2 && message.result) {
|
|
887
|
+
clearTimeout(timer)
|
|
888
|
+
const result = message.result as { thread?: { status?: { type?: unknown } }; initialTurnsPage?: { data?: unknown } }
|
|
889
|
+
if (result.thread?.status?.type === 'systemError') {
|
|
890
|
+
const turns = result.initialTurnsPage?.data
|
|
891
|
+
const latest = Array.isArray(turns) ? turns[0] : null
|
|
892
|
+
// Give a concurrently-starting turn's native notification precedence over this historical snapshot.
|
|
893
|
+
reconciliationTimer = setTimeout(() => {
|
|
894
|
+
reconciliationTimer = null
|
|
895
|
+
report(latest, 'Codex thread entered systemError before the turn observer subscribed')
|
|
896
|
+
}, 100)
|
|
897
|
+
reconciliationTimer.unref?.()
|
|
898
|
+
}
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if (message.method === 'turn/started') {
|
|
902
|
+
const params = message.params as { threadId?: unknown } | undefined
|
|
903
|
+
if (params?.threadId === threadId) cancelReconciliation()
|
|
904
|
+
}
|
|
905
|
+
if (message.method === 'turn/completed') {
|
|
906
|
+
const params = message.params as { threadId?: unknown; turn?: unknown } | undefined
|
|
907
|
+
if (params?.threadId === threadId) {
|
|
908
|
+
cancelReconciliation()
|
|
909
|
+
report(params.turn)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
conn.on('data', (chunk: Buffer) => {
|
|
914
|
+
frames.buf = Buffer.concat([frames.buf, chunk])
|
|
915
|
+
if (!upgraded) {
|
|
916
|
+
const split = frames.buf.indexOf('\r\n\r\n')
|
|
917
|
+
if (split < 0) return
|
|
918
|
+
const head = frames.buf.slice(0, split).toString('utf8')
|
|
919
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`)
|
|
920
|
+
upgraded = true
|
|
921
|
+
frames.buf = frames.buf.slice(split + 4)
|
|
922
|
+
send(wsInitialize)
|
|
923
|
+
}
|
|
924
|
+
if (drainWsFrames(frames, conn, handle)) finish('Codex app-server closed the turn observer')
|
|
925
|
+
})
|
|
926
|
+
return { close: () => finish(null), closed }
|
|
927
|
+
}
|
|
928
|
+
|
|
819
929
|
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
820
930
|
// defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
|
|
821
931
|
type CodexGenerationFence = { dir: string; generation: string }
|
|
@@ -1787,16 +1897,16 @@ function isTrackedFile(proj: string, f: string): boolean {
|
|
|
1787
1897
|
// block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
|
|
1788
1898
|
// blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
|
|
1789
1899
|
// a sibling skill/agent the user added, and NEVER any .spec data.
|
|
1790
|
-
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
1900
|
+
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts, preserveProject = false): void {
|
|
1791
1901
|
// deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
|
|
1792
1902
|
// that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
|
|
1793
1903
|
// pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
|
|
1794
1904
|
for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
|
|
1795
1905
|
const shim = h.shimFile(proj)
|
|
1796
|
-
if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
1906
|
+
if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
1797
1907
|
const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
|
|
1798
1908
|
if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
|
|
1799
|
-
h.removeTrust(proj)
|
|
1909
|
+
if (!preserveProject) h.removeTrust(proj)
|
|
1800
1910
|
const sd = h.skillDir(proj)
|
|
1801
1911
|
if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
|
|
1802
1912
|
const ad = h.agentDir(proj)
|
|
@@ -1953,6 +2063,7 @@ const noLaunchEnv = (): string[] => []
|
|
|
1953
2063
|
|
|
1954
2064
|
export const claudeHarness: Harness = {
|
|
1955
2065
|
id: 'claude',
|
|
2066
|
+
dispatchId: 'claude',
|
|
1956
2067
|
headless: false,
|
|
1957
2068
|
events: CLAUDE_EVENTS,
|
|
1958
2069
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
@@ -1963,6 +2074,7 @@ export const claudeHarness: Harness = {
|
|
|
1963
2074
|
sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
|
|
1964
2075
|
launchEnv: rendezvousLaunchEnv,
|
|
1965
2076
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
2077
|
+
shimScope: 'tree',
|
|
1966
2078
|
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
1967
2079
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
1968
2080
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
@@ -1970,7 +2082,7 @@ export const claudeHarness: Harness = {
|
|
|
1970
2082
|
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
1971
2083
|
writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
|
|
1972
2084
|
removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
|
|
1973
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2085
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
1974
2086
|
slashCommands: claudeSlashCommands,
|
|
1975
2087
|
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
1976
2088
|
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
@@ -2020,6 +2132,7 @@ export const claudeHeadlessHarness: Harness = {
|
|
|
2020
2132
|
|
|
2021
2133
|
export const codexHarness: Harness = {
|
|
2022
2134
|
id: 'codex',
|
|
2135
|
+
dispatchId: 'codex',
|
|
2023
2136
|
headless: false,
|
|
2024
2137
|
sharedRuntimeSpawn: true,
|
|
2025
2138
|
events: CODEX_EVENTS,
|
|
@@ -2038,6 +2151,7 @@ export const codexHarness: Harness = {
|
|
|
2038
2151
|
// per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
|
|
2039
2152
|
// cwd, so one shared shim serves every worktree.
|
|
2040
2153
|
shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
|
|
2154
|
+
shimScope: 'project',
|
|
2041
2155
|
// a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
|
|
2042
2156
|
// the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
|
|
2043
2157
|
// root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
|
|
@@ -2070,7 +2184,7 @@ export const codexHarness: Harness = {
|
|
|
2070
2184
|
writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
|
|
2071
2185
|
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
2072
2186
|
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
2073
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2187
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2074
2188
|
slashCommands: codexSlashCommands,
|
|
2075
2189
|
// online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
|
|
2076
2190
|
// verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
|
|
@@ -2086,6 +2200,7 @@ export const codexHarness: Harness = {
|
|
|
2086
2200
|
},
|
|
2087
2201
|
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
2088
2202
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2203
|
+
observeTurnFailures: codexTurnFailureObserver,
|
|
2089
2204
|
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2090
2205
|
coldRetirementPreflight: async (rec) => {
|
|
2091
2206
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
@@ -2354,6 +2469,7 @@ export const codexHeadlessHarness: Harness = {
|
|
|
2354
2469
|
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
2355
2470
|
export const piHarness: Harness = {
|
|
2356
2471
|
id: 'pi',
|
|
2472
|
+
dispatchId: 'pi',
|
|
2357
2473
|
headless: false,
|
|
2358
2474
|
events: PI_EVENTS,
|
|
2359
2475
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
@@ -2364,6 +2480,7 @@ export const piHarness: Harness = {
|
|
|
2364
2480
|
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
2365
2481
|
launchEnv: rendezvousLaunchEnv,
|
|
2366
2482
|
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
2483
|
+
shimScope: 'tree',
|
|
2367
2484
|
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
2368
2485
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
2369
2486
|
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
@@ -2374,7 +2491,7 @@ export const piHarness: Harness = {
|
|
|
2374
2491
|
}),
|
|
2375
2492
|
writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
|
|
2376
2493
|
removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
|
|
2377
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2494
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2378
2495
|
slashCommands: piSlashCommands,
|
|
2379
2496
|
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
2380
2497
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
@@ -2409,6 +2526,7 @@ export const piHeadlessHarness: Harness = {
|
|
|
2409
2526
|
|
|
2410
2527
|
export const opencodeHarness: Harness = {
|
|
2411
2528
|
id: 'opencode',
|
|
2529
|
+
dispatchId: 'opencode',
|
|
2412
2530
|
headless: false,
|
|
2413
2531
|
events: OPENCODE_EVENTS,
|
|
2414
2532
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
@@ -2428,6 +2546,7 @@ export const opencodeHarness: Harness = {
|
|
|
2428
2546
|
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
2429
2547
|
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
2430
2548
|
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
2549
|
+
shimScope: 'tree',
|
|
2431
2550
|
worktreeHookAnchor: () => null,
|
|
2432
2551
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
2433
2552
|
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
@@ -2437,7 +2556,7 @@ export const opencodeHarness: Harness = {
|
|
|
2437
2556
|
shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
|
|
2438
2557
|
writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
|
|
2439
2558
|
removeTrust: () => { /* nothing was written */ },
|
|
2440
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2559
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2441
2560
|
slashCommands: opencodeSlashCommands,
|
|
2442
2561
|
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
2443
2562
|
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { resolveLayout, mainBranch } from './layout.js'
|
|
|
15
15
|
import { getBoardJson } from './graphCache.js'
|
|
16
16
|
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
18
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
19
19
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
20
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
21
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
@@ -684,6 +684,7 @@ installConnectionReaper(server as unknown as HttpServer)
|
|
|
684
684
|
injectWebSocket(server)
|
|
685
685
|
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
686
686
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
687
|
+
superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
687
688
|
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
688
689
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
689
690
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, relative } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
@@ -6,10 +6,10 @@ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '
|
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
7
|
import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
8
8
|
import { git } from './git.js'
|
|
9
|
-
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
|
|
9
|
+
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig, encodeProject } from './layout.js'
|
|
10
10
|
import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
|
|
11
11
|
import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
|
|
12
|
-
import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
|
|
12
|
+
import { plantContractFilter, removeContractFilter, retireLegacyContractBlock, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
|
|
13
13
|
|
|
14
14
|
export type MaterializedArtifact = {
|
|
15
15
|
kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
|
|
@@ -34,8 +34,9 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
|
|
|
34
34
|
// implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
|
|
35
35
|
// unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
|
|
36
36
|
// mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
|
|
37
|
-
// policy (possibly to nothing).
|
|
38
|
-
//
|
|
37
|
+
// policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
|
|
38
|
+
// preserves old common ignore entries until every registered tree owns its local projection; it never reads or
|
|
39
|
+
// reconstructs a sibling policy.
|
|
39
40
|
|
|
40
41
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
41
42
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
@@ -64,8 +65,8 @@ export function contentHash(proj: string): string {
|
|
|
64
65
|
// @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
|
|
65
66
|
// they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
|
|
66
67
|
// tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
|
|
67
|
-
// run residue (.worktrees/)
|
|
68
|
-
//
|
|
68
|
+
// run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
|
|
69
|
+
// working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
|
|
69
70
|
// writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
|
|
70
71
|
// environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
|
|
71
72
|
// setup step — there is no committed-artifact delivery mode.
|
|
@@ -74,7 +75,7 @@ export function retiredAxisNotice(cfg: { render?: string; private?: boolean }):
|
|
|
74
75
|
const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
|
|
75
76
|
console.error(
|
|
76
77
|
`spexcode: the render vote is retired — ${field} is ignored. Materialized artifacts are never tracked:\n` +
|
|
77
|
-
` ignore rules live in
|
|
78
|
+
` tree-local ignore rules live in a filtered working .gitignore, and a host-tracked contract is covered by the\n` +
|
|
78
79
|
` clean/smudge filter, and a clone without spex runs \`spex materialize\` in its setup step. Remove the\n` +
|
|
79
80
|
` field from spexcode.json / spexcode.local.json to retire this notice (see \`spex guide footprint\`).`,
|
|
80
81
|
)
|
|
@@ -90,6 +91,38 @@ function isTracked(proj: string, file: string): boolean {
|
|
|
90
91
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
function registeredTrees(proj: string): string[] {
|
|
95
|
+
const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
96
|
+
return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
|
|
100
|
+
|
|
101
|
+
function hasLegacyTreeIgnore(proj: string): boolean {
|
|
102
|
+
return registeredTrees(proj).some((tree) => {
|
|
103
|
+
const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
|
|
104
|
+
return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function managedExcludeEntries(file: string): string[] {
|
|
109
|
+
if (!existsSync(file)) return []
|
|
110
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
111
|
+
const start = lines.indexOf('# spexcode:start')
|
|
112
|
+
const end = lines.indexOf('# spexcode:end', start + 1)
|
|
113
|
+
return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function selectionBody(selected: typeof HARNESSES, plugin = false): string {
|
|
117
|
+
return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function publishSelection(path: string, body: string): void {
|
|
121
|
+
const prepared = `${path}.${process.pid}.tmp`
|
|
122
|
+
writeFileSync(prepared, body)
|
|
123
|
+
renameSync(prepared, path)
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
// @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
|
|
94
127
|
// an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
|
|
95
128
|
// (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
|
|
@@ -144,11 +177,11 @@ function sweepGeneratedAgents(dir: string | null): void {
|
|
|
144
177
|
// (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
|
|
145
178
|
// `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
|
|
146
179
|
// covers everything materialized since, including products of renamed/deleted nodes.
|
|
147
|
-
|
|
180
|
+
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
148
181
|
for (const h of HARNESSES) {
|
|
149
182
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
150
183
|
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
151
|
-
h.clean(proj, arts)
|
|
184
|
+
h.clean(proj, arts, preserveProject)
|
|
152
185
|
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
|
|
153
186
|
sweepGeneratedSkills(h.skillDir(proj))
|
|
154
187
|
sweepGeneratedAgents(h.agentDir(proj))
|
|
@@ -156,8 +189,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
156
189
|
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
157
190
|
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
158
191
|
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
159
|
-
|
|
160
|
-
removeContractFilter(proj) // AFTER the blocks left the working files
|
|
192
|
+
removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
161
193
|
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
162
194
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
163
195
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
@@ -179,6 +211,22 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
179
211
|
}
|
|
180
212
|
}
|
|
181
213
|
|
|
214
|
+
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
215
|
+
const trees = registeredTrees(proj)
|
|
216
|
+
const current = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
|
|
217
|
+
for (const tree of trees) {
|
|
218
|
+
if (!existsSync(tree)) throw new Error(`cannot dematerialize project while registered worktree ${tree} is inaccessible — repair or remove/prune it first`)
|
|
219
|
+
git(['-C', tree, 'rev-parse', '--show-toplevel'])
|
|
220
|
+
}
|
|
221
|
+
for (const tree of trees) {
|
|
222
|
+
// Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
|
|
223
|
+
// same name there may be user-owned or may not exist in this tree's divergent spec at all.
|
|
224
|
+
eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
|
|
225
|
+
}
|
|
226
|
+
try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
|
|
227
|
+
removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')], true)
|
|
228
|
+
}
|
|
229
|
+
|
|
182
230
|
// the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
|
|
183
231
|
// write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
|
|
184
232
|
export function materialize(proj = process.cwd()): MaterializeResult {
|
|
@@ -198,9 +246,9 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
198
246
|
// own hand-written prose is not folded in — repo-local notes belong in the harness file's own
|
|
199
247
|
// block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
|
|
200
248
|
const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
|
|
201
|
-
// WHICH harnesses to deliver into ([[harness-select]]):
|
|
202
|
-
//
|
|
203
|
-
const cfg = readConfig(
|
|
249
|
+
// WHICH harnesses to deliver into ([[harness-select]]): this tree's explicit spexcode.json `harnesses` set.
|
|
250
|
+
// resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
251
|
+
const cfg = readConfig(proj)
|
|
204
252
|
const targets = resolveHarnessTargets(cfg.harnesses)
|
|
205
253
|
retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
|
|
206
254
|
const { selected, plugins } = partitionHarnesses(targets)
|
|
@@ -212,7 +260,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
212
260
|
// ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
|
|
213
261
|
// legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
|
|
214
262
|
// forgot them, and only the selected ones are asserted below.
|
|
215
|
-
|
|
263
|
+
eraseTree(proj, arts, true)
|
|
216
264
|
|
|
217
265
|
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
218
266
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
@@ -236,18 +284,29 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
236
284
|
const contractPaths: string[] = []
|
|
237
285
|
for (const h of selected) {
|
|
238
286
|
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
|
|
239
|
-
const shimFile = h.shimFile(proj)
|
|
240
|
-
mkdirSync(dirname(shimFile), { recursive: true })
|
|
241
287
|
const shim = h.shim(DISPATCH, SPEX)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
288
|
+
if (h.shimScope === 'tree') {
|
|
289
|
+
const shimFile = h.shimFile(proj)
|
|
290
|
+
mkdirSync(dirname(shimFile), { recursive: true })
|
|
291
|
+
writeFileSync(shimFile, shim.content)
|
|
292
|
+
record('shim', shimFile)
|
|
293
|
+
machinePaths.push(shimFile)
|
|
294
|
+
}
|
|
246
295
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
247
296
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
248
297
|
const anchor = h.worktreeHookAnchor(proj)
|
|
249
298
|
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
|
|
250
299
|
}
|
|
300
|
+
const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
|
|
301
|
+
for (const h of selectedByDispatch.values()) {
|
|
302
|
+
const shim = h.shim(DISPATCH, SPEX)
|
|
303
|
+
if (h.shimScope === 'project') {
|
|
304
|
+
const file = h.shimFile(proj)
|
|
305
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
|
|
306
|
+
record('shim', file)
|
|
307
|
+
}
|
|
308
|
+
for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
|
|
309
|
+
}
|
|
251
310
|
// (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
|
|
252
311
|
// harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
|
|
253
312
|
for (const sk of skillNodes) {
|
|
@@ -299,44 +358,54 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
299
358
|
}
|
|
300
359
|
}
|
|
301
360
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
302
|
-
// (9)
|
|
303
|
-
//
|
|
304
|
-
// DECLARATION every other git door consults (checkout may overwrite, clean -fd spares, status/add -A/
|
|
305
|
-
// stash stay silent). The host's tracked .gitignore is never touched.
|
|
306
|
-
// Entries must be CHECKOUT-INVARIANT: the exclude lives in the COMMON git dir shared by the main checkout
|
|
307
|
-
// and every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
|
|
308
|
-
// proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
|
|
309
|
-
// pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
|
|
361
|
+
// (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
|
|
362
|
+
// info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
|
|
310
363
|
const mc = mainCheckout(proj)
|
|
311
|
-
const anchor = (abs: string): string | null => {
|
|
312
|
-
const p = relative(proj, abs); if (!p.startsWith('..')) return p
|
|
313
|
-
const m = relative(mc, abs); if (!m.startsWith('..')) return m
|
|
314
|
-
return null
|
|
315
|
-
}
|
|
316
|
-
// machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
|
|
317
|
-
// abs path), spexcode.local.json (the host overlay — a `git add -A` must never leak it), and the session
|
|
318
|
-
// residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
|
|
319
|
-
// an old backend wrote). Static strings stay checkout-invariant.
|
|
320
364
|
const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
|
|
321
|
-
const
|
|
322
|
-
...[...
|
|
365
|
+
const commonEntries = [
|
|
366
|
+
...[...new Set(HARNESSES.filter((h) => h.shimScope === 'project' && existsSync(h.shimFile(proj)) &&
|
|
367
|
+
readFileSync(h.shimFile(proj), 'utf8').includes('dispatch.sh')).map((h) => relative(mc, h.shimFile(proj))))]
|
|
368
|
+
.filter((p) => !p.startsWith('..')),
|
|
323
369
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
324
370
|
]
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
371
|
+
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
372
|
+
const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
|
|
373
|
+
|
|
374
|
+
// Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
|
|
375
|
+
// working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
|
|
328
376
|
const filterContracts: string[] = []
|
|
329
377
|
const oursContracts: string[] = []
|
|
330
378
|
for (const f of contractPaths) {
|
|
331
379
|
if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
|
|
332
380
|
else oursContracts.push(f)
|
|
333
381
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
382
|
+
const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
|
|
383
|
+
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
384
|
+
const ignoreFile = join(proj, '.gitignore')
|
|
385
|
+
const ignoreTracked = isTracked(proj, ignoreFile)
|
|
386
|
+
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
387
|
+
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
388
|
+
const ignoreBody = entries(localEntries)
|
|
389
|
+
writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
|
|
390
|
+
|
|
391
|
+
const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
|
|
392
|
+
if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
|
|
393
|
+
const bindings: ContractFilterBinding[] = [
|
|
394
|
+
...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
|
|
395
|
+
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->', legacy: true })),
|
|
396
|
+
{ file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
|
|
397
|
+
]
|
|
398
|
+
if (payloads.length) plantContractFilter(proj, payloads, bindings)
|
|
399
|
+
// (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
|
|
400
|
+
// final receipt; a killed writer leaves the preceding successful selection intact.
|
|
339
401
|
const h = contentHash(proj)
|
|
340
402
|
writeFileSync(join(rt, 'content-hash'), h)
|
|
403
|
+
writeFileSync(join(rt, 'contract-filter-v2'), '')
|
|
404
|
+
writeFileSync(join(rt, TREE_IGNORE_RECEIPT), '')
|
|
405
|
+
writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
406
|
+
retireLegacyContractBlock(proj)
|
|
407
|
+
const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
|
|
408
|
+
writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
|
|
409
|
+
publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
|
|
341
410
|
return { contentHash: h, planted }
|
|
342
411
|
}
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
8
8
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
9
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 DispatchResult, type PaneProbe, type ProcTable } from './harness.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'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
12
|
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
13
|
import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
@@ -1651,6 +1651,106 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
1651
1651
|
void tick()
|
|
1652
1652
|
}
|
|
1653
1653
|
|
|
1654
|
+
type TurnFailureObserverState = {
|
|
1655
|
+
fingerprint: string
|
|
1656
|
+
subscription: FailureSubscription | null
|
|
1657
|
+
startedAt: number
|
|
1658
|
+
failures: number
|
|
1659
|
+
retryAt: number
|
|
1660
|
+
lastReason: string | null
|
|
1661
|
+
}
|
|
1662
|
+
const turnFailureObservers = new Map<string, TurnFailureObserverState>()
|
|
1663
|
+
let supervisingTurnFailures = false
|
|
1664
|
+
const TURN_FAILURE_OBSERVER_STABLE_MS = 5000
|
|
1665
|
+
|
|
1666
|
+
export function turnFailureNote(harness: string, failure: TurnFailure): string {
|
|
1667
|
+
const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed'
|
|
1668
|
+
const at = failure.completedAt == null ? '' : ` at ${new Date(failure.completedAt * 1000).toISOString()}`
|
|
1669
|
+
return `${harness} turn failed${at}: ${message}`
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
export function turnFailureRetryDelay(failures: number): number {
|
|
1673
|
+
return Math.min(30_000, 1000 * 2 ** Math.max(0, Math.min(failures - 1, 5)))
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function deferTurnFailureObserver(id: string, harness: string, state: TurnFailureObserverState, reason: string): void {
|
|
1677
|
+
state.subscription = null
|
|
1678
|
+
state.failures++
|
|
1679
|
+
const delay = turnFailureRetryDelay(state.failures)
|
|
1680
|
+
state.retryAt = Date.now() + delay
|
|
1681
|
+
if (state.lastReason !== reason)
|
|
1682
|
+
console.warn(`[spex ${harness}] turn failure observer for ${id} disconnected (${reason}); retrying in ${delay}ms`)
|
|
1683
|
+
state.lastReason = reason
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Reconcile one adapter-owned native failure subscription per live governed session. Product code knows only
|
|
1687
|
+
// the optional interface capability; Codex owns WebSocket/thread semantics and Claude keeps using StopFailure.
|
|
1688
|
+
export function reconcileTurnFailureObservers(): void {
|
|
1689
|
+
const wanted = new Map<string, { rec: SessRec; harness: Harness; fingerprint: string }>()
|
|
1690
|
+
for (const id of listSessionIds()) {
|
|
1691
|
+
let rec: SessRec | null = null
|
|
1692
|
+
try { rec = readRecord(id) } catch { continue }
|
|
1693
|
+
if (!rec?.governed || rec.stopped || rec.archived || !rec.harnessSessionId) continue
|
|
1694
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
1695
|
+
if (!harness.observeTurnFailures) continue
|
|
1696
|
+
wanted.set(id, { rec, harness, fingerprint: `${harness.id}:${rec.harnessSessionId}:${runtimeRoot()}` })
|
|
1697
|
+
}
|
|
1698
|
+
for (const [id, state] of turnFailureObservers) {
|
|
1699
|
+
if (wanted.get(id)?.fingerprint === state.fingerprint) continue
|
|
1700
|
+
turnFailureObservers.delete(id)
|
|
1701
|
+
state.subscription?.close()
|
|
1702
|
+
}
|
|
1703
|
+
for (const [id, target] of wanted) {
|
|
1704
|
+
const now = Date.now()
|
|
1705
|
+
let state = turnFailureObservers.get(id)
|
|
1706
|
+
if (state?.subscription) {
|
|
1707
|
+
if (state.failures > 0 && now - state.startedAt >= TURN_FAILURE_OBSERVER_STABLE_MS) {
|
|
1708
|
+
state.failures = 0
|
|
1709
|
+
state.retryAt = 0
|
|
1710
|
+
state.lastReason = null
|
|
1711
|
+
}
|
|
1712
|
+
continue
|
|
1713
|
+
}
|
|
1714
|
+
if (state && now < state.retryAt) continue
|
|
1715
|
+
state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null }
|
|
1716
|
+
state.startedAt = now
|
|
1717
|
+
turnFailureObservers.set(id, state)
|
|
1718
|
+
try {
|
|
1719
|
+
const subscription = target.harness.observeTurnFailures!({
|
|
1720
|
+
session: id,
|
|
1721
|
+
worktreePath: target.rec.worktreePath,
|
|
1722
|
+
harnessSessionId: target.rec.harnessSessionId,
|
|
1723
|
+
runtimeDir: runtimeRoot(),
|
|
1724
|
+
launchCmd: target.rec.launchCmd,
|
|
1725
|
+
}, (failure) => {
|
|
1726
|
+
if (turnFailureObservers.get(id)?.fingerprint !== target.fingerprint) return
|
|
1727
|
+
try { markTurnFailure(id, turnFailureNote(target.harness.id, failure)) }
|
|
1728
|
+
catch (error) { console.error(`[spex ${target.harness.id}] could not record native turn failure for ${id}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
1729
|
+
})
|
|
1730
|
+
state.subscription = subscription
|
|
1731
|
+
void subscription.closed.then((reason) => {
|
|
1732
|
+
if (turnFailureObservers.get(id) !== state) return
|
|
1733
|
+
if (reason) deferTurnFailureObserver(id, target.harness.id, state, reason)
|
|
1734
|
+
else turnFailureObservers.delete(id)
|
|
1735
|
+
})
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error))
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
export function superviseTurnFailures(intervalMs = 1000): void {
|
|
1743
|
+
if (supervisingTurnFailures) return
|
|
1744
|
+
supervisingTurnFailures = true
|
|
1745
|
+
const tick = () => {
|
|
1746
|
+
try { reconcileTurnFailureObservers() }
|
|
1747
|
+
catch (error) { console.error(`spex: turn failure reconciliation failed: ${error instanceof Error ? error.message : String(error)}`) }
|
|
1748
|
+
const timer = setTimeout(tick, intervalMs)
|
|
1749
|
+
timer.unref?.()
|
|
1750
|
+
}
|
|
1751
|
+
tick()
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1654
1754
|
// @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
|
|
1655
1755
|
// "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
|
|
1656
1756
|
// the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
|
|
@@ -2105,19 +2205,23 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
|
|
|
2105
2205
|
}
|
|
2106
2206
|
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
2107
2207
|
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
2108
|
-
// @@@
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
export function
|
|
2112
|
-
if (
|
|
2208
|
+
// @@@ harness turn failure - native adapter failures are external runtime facts that must become visible on
|
|
2209
|
+
// the durable board. Compare-and-set only an undeclared active record, so a declaration that landed before a
|
|
2210
|
+
// late process close or app-server completion remains authoritative.
|
|
2211
|
+
export function markTurnFailure(sessionId: string | undefined, note: string): boolean {
|
|
2212
|
+
if (!sessionId) return false
|
|
2113
2213
|
return runSessionOperationSync({ op: 'lifecycle-transition', sessionId }, () => withRecordLockSync(sessionId, () => {
|
|
2114
2214
|
const rec = readLiveRecord(sessionId)
|
|
2115
|
-
if (!rec || rec.status !== 'active') return false
|
|
2116
|
-
|
|
2117
|
-
writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
|
|
2215
|
+
if (!rec || rec.status !== 'active' || rec.stopped || rec.archived) return false
|
|
2216
|
+
writeRecord({ ...rec, status: 'error', proposal: null, note })
|
|
2118
2217
|
return true
|
|
2119
2218
|
}))
|
|
2120
2219
|
}
|
|
2220
|
+
export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
|
|
2221
|
+
if (exitCode === '0') return false
|
|
2222
|
+
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
|
|
2223
|
+
return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`)
|
|
2224
|
+
}
|
|
2121
2225
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
2122
2226
|
const id = sessionId || ownSessionId()
|
|
2123
2227
|
if (!id || !harnessSessionId) return false
|
|
@@ -7,7 +7,9 @@ events:
|
|
|
7
7
|
- StopFailure
|
|
8
8
|
order: 10
|
|
9
9
|
block: false
|
|
10
|
+
code:
|
|
11
|
+
- .spec/project/.plugins/core/session-fail/fail.sh
|
|
10
12
|
---
|
|
11
13
|
When a turn ends not because the agent declared but because the API itself failed, this hook structurally marks the session `error`. A failed turn is a real outcome the board must show, and without this signal the session would freeze under whatever state it last held — reading as "active" or "awaiting" long after it actually died.
|
|
12
14
|
|
|
13
|
-
It is non-blocking
|
|
15
|
+
It is non-blocking on the failure event: the failure already happened, so the only job is to report it truthfully. As a board-lifecycle hook it acts only on a GOVERNED session — resolved in the global store from the payload's `session_id` — and writes via `spex internal session-fail --session <id>`. That machine entry reaches the same live-active compare-and-set as Codex's native failed completion and a headless turn's non-zero exit (harness-adapter): only an undeclared, non-stopped `active` record becomes `error`. A declaration, explicit stop, or archive that landed first remains authoritative; a late native failure never rewrites it. This one writer keeps the [[stop-gate]] family's invariant intact for every harness while each adapter retains only its native failure signal.
|
|
@@ -18,6 +18,6 @@ The body is the contract; update it with code when intent changes.
|
|
|
18
18
|
2. COMMIT BEFORE YOU DECLARE. Commit the spec and the code it justifies before declaring done or proposing merge.
|
|
19
19
|
Independent intent gets its own sibling node; do not ride it on an assigned node.
|
|
20
20
|
3. THE BODY IS A LIVING CURRENT-STATE DOCUMENT. Rewrite present intent in place; never add a `## vN` changelog.
|
|
21
|
-
4. KEEP THE LOSS SIGNAL HONEST. `spex spec lint` is the blocking correctness gate;
|
|
22
|
-
reports measurement gaps. Re-run changed eval scenarios through the real product, commit the verified tree, then
|
|
21
|
+
4. KEEP THE LOSS SIGNAL HONEST. Before declaring, run both: `spex spec lint` is the blocking correctness gate;
|
|
22
|
+
`spex eval lint --changed` reports measurement gaps. Re-run changed eval scenarios through the real product, commit the verified tree, then
|
|
23
23
|
file with `spex eval add`; the reading's `codeSha` must name that commit, and evidence must fit the behavior.
|