@gotcos/glasses-server 6.36.10 → 6.36.12
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/CHANGELOG.md +115 -0
- package/package.json +2 -2
- package/server/lib/agent-session-search.ts +118 -40
- package/server/lib/agent-session-store.ts +43 -1
- package/server/lib/attached-provider-adapter.ts +21 -1
- package/server/lib/thread-fence-store.ts +63 -0
- package/server/routes/agent-session-bindings.ts +86 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.36.12
|
|
4
|
+
- **Claude and Codex now rank candidates by recency before spending the budget.** Both
|
|
5
|
+
walked in raw `readdir` order, so which sessions were reachable came down to filesystem
|
|
6
|
+
layout — `collectCursorDocs` had ranked for a while and these two were the inconsistent
|
|
7
|
+
ones. Statting every candidate first costs ~41ms across 2,118 files. Cursor also charged
|
|
8
|
+
its budget before the keep-warm filter, the same defect, now fixed.
|
|
9
|
+
- **The reach claim in the module docstring was never true and is now measured.** It said
|
|
10
|
+
"older chats stay findable". Ranked and measured on this machine, search reaches roughly
|
|
11
|
+
24 days of Claude, 89 days of Codex and 19 days of Cursor, because once candidates are
|
|
12
|
+
ordered by recency the per-provider doc budget IS the horizon. `EXAMINE_MULTIPLE` was
|
|
13
|
+
re-swept and raised to 12 — the point where examining more files stops finding anything
|
|
14
|
+
older and the doc budget takes over. A sampled older stratum was considered and
|
|
15
|
+
rejected: partial coverage makes a miss uninterpretable, and a search that silently
|
|
16
|
+
samples cannot tell you whether something is absent or merely unsampled.
|
|
17
|
+
- **Embedding batches go out together instead of one after another.** The loop awaited
|
|
18
|
+
each batch in turn, so cost was a round trip per 64 docs and grew as the collector
|
|
19
|
+
returned more — 389 docs is 7 serialized trips at the old size. Now 128 per request,
|
|
20
|
+
all in flight at once. Structural; not measured end to end, because this harness has no
|
|
21
|
+
OpenAI key and the running server was left alone.
|
|
22
|
+
- **Session search was 68% scaffolding, and the budget was the reason.** Of the 1,296
|
|
23
|
+
Claude transcripts on this machine the collector indexed 41, and 28 of those 41 were
|
|
24
|
+
machine prompts — 22 Slack Bridge proxy, 4 reply-with, 2 slack_search_users. Roughly 13
|
|
25
|
+
real conversations were searchable, which is why searching an exact session title
|
|
26
|
+
returned nothing. Two changes, which do not work apart: `isKeepWarmSessionTitle` now
|
|
27
|
+
recognises the machine families by anchored prefix, and both the Claude and Codex
|
|
28
|
+
collectors run that filter — and the Codex `thread_source === 'subagent'` check — BEFORE
|
|
29
|
+
charging the doc budget rather than after. Measured against the real corpus: 41 indexed
|
|
30
|
+
Claude docs with 28 junk becomes **79 indexed with 0 junk**.
|
|
31
|
+
- **The budget now counts docs kept, not files opened.** That is the whole fix: a run of
|
|
32
|
+
machine transcripts used to consume the 134-file allowance and return nothing, so the
|
|
33
|
+
newest real transcript on disk was never reached. A second `examined` ceiling
|
|
34
|
+
(`EXAMINE_MULTIPLE`, 5x the doc budget) stops a pathological corpus walking all 1,296
|
|
35
|
+
files, and the expensive transcript-body read is deferred until a file is being kept.
|
|
36
|
+
- **This also removes rows from COS Control's session LIST.** The predicate is shared by
|
|
37
|
+
all four collectors and the list path, so keep-warm and Slack Bridge entries stop
|
|
38
|
+
appearing there too. That is intended, not a side effect to fix.
|
|
39
|
+
- **Control may not see any of this yet.** The search route's median is ~2.27s against
|
|
40
|
+
Control's 2s client timeout, of which ~1.7s is two sequential embedding round trips;
|
|
41
|
+
this work adds ~350ms on top. Until that timeout and `EMBED_BATCH` are addressed,
|
|
42
|
+
Control falls back to its local scanner and reports a fabricated `server_too_old`.
|
|
43
|
+
- **The scan budget had no test coverage at all.** `collectAgentSessionSearchDocs` was
|
|
44
|
+
never called by any test and its `cap` was never exercised, so the branch that spends
|
|
45
|
+
the budget had never run. 21 tests added, each verified to FAIL against the previous
|
|
46
|
+
code before being kept.
|
|
47
|
+
|
|
48
|
+
## 6.36.11
|
|
49
|
+
- **Fences now record WHY, so the population can be measured before anything resolves
|
|
50
|
+
automatically.** Two plans designed an automatic fence resolver and both were rejected —
|
|
51
|
+
the second because there has never been a single fence on the machine to look at. If
|
|
52
|
+
`timeout` dominates, the child had the full 21-minute budget to run tool calls before
|
|
53
|
+
SIGKILL and re-delivery would re-execute them, so no automatic clear is ever safe. That
|
|
54
|
+
question was unanswerable and now is not.
|
|
55
|
+
- **`reaped` is reported by the adapter, never derived.** A signal-killed child reports
|
|
56
|
+
`code === null` and the handlers only assign `exitCode` for a numeric code — so deriving
|
|
57
|
+
"was it reaped" from `exitCode` reports NEVER REAPED for the dominant timeout shape
|
|
58
|
+
(SIGTERM, then SIGKILL), which is exactly backwards for the decision this data informs.
|
|
59
|
+
The first cut of this change did derive it. `AttachedTurnFailureResult` now carries
|
|
60
|
+
`reaped`, true from every settle reached via `close`/`error` and false only from the
|
|
61
|
+
force-settle that fires when `close` never arrived.
|
|
62
|
+
- **An unreadable adapter result records nothing, not zeroes.** Reading `{}` and writing
|
|
63
|
+
`exitCode: null, childReaped: false` states two facts about a child nothing is known
|
|
64
|
+
about, indistinguishable on disk from a confirmed-unreaped timeout — corrupting the one
|
|
65
|
+
discriminator this evidence exists to establish.
|
|
66
|
+
- **`fenceSite` says which site fired.** `adapterReason` cannot substitute: the catch site
|
|
67
|
+
inherits whatever the adapter last reported, so a route crash AFTER a clean delivery
|
|
68
|
+
records `ok` — the strongest possible reason NOT to re-deliver, which would otherwise
|
|
69
|
+
read as "nothing went wrong".
|
|
70
|
+
- **One resolved reason for the record and the log.** The record said `unreadable_result`
|
|
71
|
+
while the breadcrumb said `unknown`, so an operator grepping for the sentinel found
|
|
72
|
+
nothing. This is the same contradiction 6.36.10 fixed at the other fence site,
|
|
73
|
+
re-committed one release later in the same handler; both now read one value.
|
|
74
|
+
- **A release no longer destroys the evidence.** `releaseFence` deletes the row, and the
|
|
75
|
+
realistic first-fence sequence is: fence lands, Control's card appears, it is released,
|
|
76
|
+
the distribution is gone. The release breadcrumb now carries the whole record.
|
|
77
|
+
- **Spawn identity as `{pid, startMs}` PAIRS.** `recordedPids` held bare pids and the
|
|
78
|
+
measured start was discarded; a pid alone cannot be told apart from a recycled one.
|
|
79
|
+
The evidence type is narrowed to the six adapter fields so the spread at the fence
|
|
80
|
+
sites cannot clobber the fence's own identity — `Partial<FenceEvidence>` permitted
|
|
81
|
+
`provider`, and the adapter result carries one that would write null and fail
|
|
82
|
+
`isFenceRecord` on the next read, silently un-enforcing the fence.
|
|
83
|
+
- **All fields OPTIONAL and NOT in `isFenceRecord`.** That predicate is cast-based, so a
|
|
84
|
+
required field would type as present while being undefined at runtime; extending it
|
|
85
|
+
would reclassify existing rows as unrecognised and silently un-enforce them.
|
|
86
|
+
- **DISK ONLY.** Nothing reaches the wire; asserted against the real `/fences` body and
|
|
87
|
+
the release preview.
|
|
88
|
+
- **No behaviour change.** Nothing clears, nothing refuses differently. Upgrade, downgrade
|
|
89
|
+
and the COS Control card were all verified unaffected.
|
|
90
|
+
- **Coverage, stated honestly.** The adapter's `reaped` contract is tested at the adapter,
|
|
91
|
+
driving a real `close(null)` — a route test could not cover it, because the route
|
|
92
|
+
fixtures supply `reaped` themselves and would pass with the adapter gutted. Mutation
|
|
93
|
+
results: caught — derived-from-exitCode, adapter stops reporting reaped, startMs zeroed,
|
|
94
|
+
empty spawn list, missing fenceSite, unrecorded adapterReason, wire leak. **Survived, and
|
|
95
|
+
therefore unverified: `stderrClass` is written but asserted nowhere, and `fail()`'s
|
|
96
|
+
`reaped: false` default on the `not_attempted` paths (which never fence).** The
|
|
97
|
+
`route_error` fence site remains reachable by no test.
|
|
98
|
+
- **Known gaps, not fixed here:** `stderrClass` appears in the breadcrumbs but on a
|
|
99
|
+
default install (`COS_THREAD_FENCE_DURABLE` unset) nothing is written to disk at all;
|
|
100
|
+
and reading the distribution means reading `thread-fences.json` or the server log —
|
|
101
|
+
there is no UI for it.
|
|
102
|
+
- **A live session was reporting itself hours idle.** Separate from the fence work above.
|
|
103
|
+
`liveClaudeRows` builds a row's `modified` from the peer registry's `lastActiveAt`, which
|
|
104
|
+
tracks the REGISTRY record and not the transcript — so a session that is actively writing
|
|
105
|
+
keeps reporting whenever the registry last moved. Measured on three live sessions
|
|
106
|
+
2026-08-18: the wire said 55.3m / 407.7m / 435.0m old while their transcripts had been
|
|
107
|
+
written 0.1m / 0.2m / 5.1m earlier. Under-reporting by up to 7.2 hours. Shipped in
|
|
108
|
+
66dff88; `enrichLiveClaude` already resolves the transcript path and reads the file twice,
|
|
109
|
+
so the true mtime costs one `stat`. A resolved file that fails to stat keeps the
|
|
110
|
+
heartbeat; a session with no transcript at all (2 of 6 measured) returns early on the
|
|
111
|
+
existing guard. Prerequisite for any surface that renders a real date — without it,
|
|
112
|
+
showing the timestamp displays an actively-writing session as seven hours stale.
|
|
113
|
+
Coverage: `enrichLiveClaude` had NO execution coverage before this (every existing test
|
|
114
|
+
passes an empty live array). Two tests now drive it through `listAgentSessions`. Three
|
|
115
|
+
mutations, two caught; the third (the stat-failure fallback) SURVIVES because the
|
|
116
|
+
missing-file guard returns first, so that branch is unreached. The code says so.
|
|
117
|
+
|
|
3
118
|
## 6.36.10
|
|
4
119
|
- **A fenced thread had no exit and left no trace.** An ambiguous delivery fences the
|
|
5
120
|
target so a prompt cannot be double-delivered into a real conversation — that is
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.36.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.36.12",
|
|
4
|
+
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* Keyword: local title / sidebar name / first prompt / transcript scan. No model.
|
|
5
5
|
* Semantic: one OpenAI query embedding scored against those same texts.
|
|
6
6
|
* Sessions have no Qdrant collection — this is not meeting or memory search.
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* REACH, measured 2026-08-18 rather than claimed. The 7-day list window does not apply,
|
|
9
|
+
* but this is not unbounded either: each provider gets a doc budget of MAX_SCAN_FILES/3,
|
|
10
|
+
* and once candidates are ranked by recency that budget IS the horizon. On this machine
|
|
11
|
+
* that is roughly 24 days of Claude, 89 days of Codex and 19 days of Cursor. An older
|
|
12
|
+
* chat is reachable only if its provider has not filled its budget with newer ones.
|
|
13
|
+
*
|
|
14
|
+
* This previously read "older chats stay findable", which was written before ranking and
|
|
15
|
+
* was never measured. Widening it means a bigger budget, which costs embedding time on
|
|
16
|
+
* the route -- not a deeper examine ceiling, which saturates. A sampled older stratum was
|
|
17
|
+
* considered and rejected: partial coverage makes a miss uninterpretable, and a search
|
|
18
|
+
* that silently samples cannot tell you whether a thing is absent or merely unsampled.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
import { join } from 'node:path'
|
|
@@ -46,11 +57,51 @@ import {
|
|
|
46
57
|
|
|
47
58
|
const HEAD_TEXT = 8_000
|
|
48
59
|
const MAX_SCAN_FILES = 400
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many files a provider may OPEN per search, as a multiple of the docs it may KEEP.
|
|
63
|
+
*
|
|
64
|
+
* Before the filter moved ahead of the decrement, `remaining` meant "files read" -- so a
|
|
65
|
+
* run of machine transcripts consumed the whole allowance and returned nothing. Now it
|
|
66
|
+
* means "docs kept", which on its own would let a pathological corpus walk all 1,296
|
|
67
|
+
* files. This is the ceiling that stops it.
|
|
68
|
+
*
|
|
69
|
+
* Swept 2026-08-18 against the real corpus (1,296 Claude transcripts, 822 Codex
|
|
70
|
+
* rollouts). Docs kept vs collector wall time, median of 5 warm runs:
|
|
71
|
+
*
|
|
72
|
+
* Re-swept after recency ranking landed, since ranking changes which files the ceiling
|
|
73
|
+
* is spent on -- the newest region of the corpus is the densest in machine transcripts:
|
|
74
|
+
*
|
|
75
|
+
* x5 329 docs 74 claude 11.4d window 1287 ms
|
|
76
|
+
* x8 389 docs 134 claude 21.3d window 1585 ms
|
|
77
|
+
* x12 389 docs 134 claude 24.5d window 1697 ms <- chosen
|
|
78
|
+
* x16 389 docs 134 claude 24.5d window 1637 ms
|
|
79
|
+
*
|
|
80
|
+
* x12 is where the ceiling stops being the constraint and the 134-doc budget takes over:
|
|
81
|
+
* past it, examining more files finds nothing older. That is the principled stopping
|
|
82
|
+
* point -- raise until the other limit binds, then stop.
|
|
83
|
+
*/
|
|
84
|
+
const EXAMINE_MULTIPLE = 12
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `remaining` counts docs KEPT. `examined` counts files OPENED. They are different
|
|
88
|
+
* numbers because most files on this machine are filtered out after their title is read.
|
|
89
|
+
*/
|
|
90
|
+
type ScanBudget = { remaining: number; examined: number }
|
|
49
91
|
const SEMANTIC_DOC_CAP = 120
|
|
50
92
|
const SEMANTIC_MIN = 0.28
|
|
51
93
|
const EMBED_MODEL = 'text-embedding-3-small'
|
|
52
94
|
const EMBED_TIMEOUT_MS = 12_000
|
|
53
|
-
|
|
95
|
+
/**
|
|
96
|
+
* 128 inputs per request, and the requests go out TOGETHER.
|
|
97
|
+
*
|
|
98
|
+
* This loop used to `await` each batch in turn, so the embedding cost was a round trip
|
|
99
|
+
* per 64 docs -- about 1.7s of a 2.3s route, and it got worse as the collector started
|
|
100
|
+
* returning more docs (389 docs is 7 serialized trips at the old batch size, 4 at this
|
|
101
|
+
* one). Serialization was the expensive part, not the batch size, so both changed:
|
|
102
|
+
* fewer requests, and one round trip of latency instead of N.
|
|
103
|
+
*/
|
|
104
|
+
const EMBED_BATCH = 128
|
|
54
105
|
|
|
55
106
|
export interface AgentSessionSearchHit extends AgentSessionRow {
|
|
56
107
|
snippet: string
|
|
@@ -139,7 +190,9 @@ export async function defaultEmbedTexts(texts: string[]): Promise<number[][] | {
|
|
|
139
190
|
if (!key) return { reason: 'no_session_embeddings' }
|
|
140
191
|
if (texts.length === 0) return []
|
|
141
192
|
const vectors: number[][] = new Array(texts.length)
|
|
142
|
-
|
|
193
|
+
const offsets: number[] = []
|
|
194
|
+
for (let offset = 0; offset < texts.length; offset += EMBED_BATCH) offsets.push(offset)
|
|
195
|
+
const failed = await Promise.all(offsets.map(async offset => {
|
|
143
196
|
const slice = texts.slice(offset, offset + EMBED_BATCH).map(text => text.slice(0, HEAD_TEXT))
|
|
144
197
|
try {
|
|
145
198
|
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
@@ -151,17 +204,19 @@ export async function defaultEmbedTexts(texts: string[]): Promise<number[][] | {
|
|
|
151
204
|
body: JSON.stringify({ model: EMBED_MODEL, input: slice }),
|
|
152
205
|
signal: AbortSignal.timeout(EMBED_TIMEOUT_MS),
|
|
153
206
|
})
|
|
154
|
-
if (!response.ok) return
|
|
207
|
+
if (!response.ok) return true
|
|
155
208
|
const parsed = await response.json() as { data?: Array<{ embedding?: number[]; index?: number }> }
|
|
156
209
|
const rows = Array.isArray(parsed.data) ? parsed.data : []
|
|
157
210
|
for (const row of rows) {
|
|
158
211
|
const index = typeof row.index === 'number' ? row.index : 0
|
|
159
212
|
if (Array.isArray(row.embedding)) vectors[offset + index] = row.embedding
|
|
160
213
|
}
|
|
214
|
+
return false
|
|
161
215
|
} catch {
|
|
162
|
-
return
|
|
216
|
+
return true
|
|
163
217
|
}
|
|
164
|
-
}
|
|
218
|
+
}))
|
|
219
|
+
if (failed.some(Boolean)) return { reason: 'embeddings_unreachable' }
|
|
165
220
|
if (vectors.some(row => !Array.isArray(row) || row.length === 0)) return { reason: 'embeddings_unreachable' }
|
|
166
221
|
return vectors
|
|
167
222
|
}
|
|
@@ -180,40 +235,53 @@ function pushDoc(docs: SearchDoc[], next: SearchDoc) {
|
|
|
180
235
|
}
|
|
181
236
|
}
|
|
182
237
|
|
|
183
|
-
async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget:
|
|
238
|
+
async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: ScanBudget) {
|
|
184
239
|
const starred = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
240
|
+
// Stat every candidate and rank by recency BEFORE spending anything, the shape
|
|
241
|
+
// `collectCursorDocs` already used. Claude and Codex were the inconsistent ones: they
|
|
242
|
+
// walked in raw `readdir` order, so which sessions were reachable came down to
|
|
243
|
+
// filesystem layout and the newest transcript on disk was routinely never opened.
|
|
244
|
+
// Statting is not the expensive part -- measured at 41ms across 2,118 candidates.
|
|
245
|
+
const candidates: Array<{ file: string; native: string; folder: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
185
246
|
for (const folder of await dirents(roots.claudeProjects)) {
|
|
186
247
|
const dir = join(roots.claudeProjects, folder)
|
|
187
248
|
for (const name of await dirents(dir)) {
|
|
188
|
-
if (!CLAUDE_UUID_JSONL.test(name)
|
|
249
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
189
250
|
const file = join(dir, name)
|
|
190
251
|
const st = await fileStat(file)
|
|
191
252
|
if (!st?.isFile) continue
|
|
192
|
-
|
|
193
|
-
const native = name.slice(0, -6)
|
|
194
|
-
const custom = await lastCustomTitle(file)
|
|
195
|
-
const first = await firstClaudeUserTitle(file)
|
|
196
|
-
const users = await transcriptHaystack('claude', file)
|
|
197
|
-
const title = custom || first || 'Claude session'
|
|
198
|
-
if (isKeepWarmSessionTitle(title)) continue
|
|
199
|
-
const haystack = clip(`${title}\n${custom || ''}\n${first || ''}\n${workspaceLabel(folder)}\n${users}`)
|
|
200
|
-
pushDoc(docs, {
|
|
201
|
-
title,
|
|
202
|
-
haystack,
|
|
203
|
-
row: {
|
|
204
|
-
session_id: native,
|
|
205
|
-
provider: 'claude',
|
|
206
|
-
display_label: title,
|
|
207
|
-
project: workspaceLabel(folder),
|
|
208
|
-
modified: isoFromMtime(st.mtimeMs),
|
|
209
|
-
created: isoFromMtime(st.birthtimeMs),
|
|
210
|
-
alive: false,
|
|
211
|
-
state: 'recent',
|
|
212
|
-
pinned: starred.has(native.toLowerCase()),
|
|
213
|
-
},
|
|
214
|
-
})
|
|
253
|
+
candidates.push({ file, native: name.slice(0, -6), folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs })
|
|
215
254
|
}
|
|
216
255
|
}
|
|
256
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
257
|
+
for (const candidate of candidates) {
|
|
258
|
+
if (budget.remaining <= 0 || budget.examined <= 0) break
|
|
259
|
+
budget.examined -= 1
|
|
260
|
+
const custom = await lastCustomTitle(candidate.file)
|
|
261
|
+
const first = await firstClaudeUserTitle(candidate.file)
|
|
262
|
+
const title = custom || first || 'Claude session'
|
|
263
|
+
// Both filters run BEFORE the doc budget is charged, and the haystack -- by far the
|
|
264
|
+
// most expensive read here -- runs only for a file we are actually keeping.
|
|
265
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
266
|
+
budget.remaining -= 1
|
|
267
|
+
const users = await transcriptHaystack('claude', candidate.file)
|
|
268
|
+
const haystack = clip(`${title}\n${custom || ''}\n${first || ''}\n${workspaceLabel(candidate.folder)}\n${users}`)
|
|
269
|
+
pushDoc(docs, {
|
|
270
|
+
title,
|
|
271
|
+
haystack,
|
|
272
|
+
row: {
|
|
273
|
+
session_id: candidate.native,
|
|
274
|
+
provider: 'claude',
|
|
275
|
+
display_label: title,
|
|
276
|
+
project: workspaceLabel(candidate.folder),
|
|
277
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
278
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
279
|
+
alive: false,
|
|
280
|
+
state: 'recent',
|
|
281
|
+
pinned: starred.has(candidate.native.toLowerCase()),
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
}
|
|
217
285
|
for (const starredId of starred) {
|
|
218
286
|
if (docs.some(doc => doc.row.provider === 'claude' && doc.row.session_id.toLowerCase() === starredId)) continue
|
|
219
287
|
if (budget.remaining <= 0) break
|
|
@@ -221,10 +289,10 @@ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], bu
|
|
|
221
289
|
if (!desktop) continue
|
|
222
290
|
const st = await fileStat(desktop)
|
|
223
291
|
if (!st?.isFile) continue
|
|
224
|
-
budget.remaining -= 1
|
|
225
292
|
const head = peekClaudeDesktopHead(await readWindow(desktop, false))
|
|
226
293
|
const title = head.title || 'Claude session'
|
|
227
294
|
if (isKeepWarmSessionTitle(title)) continue
|
|
295
|
+
budget.remaining -= 1
|
|
228
296
|
pushDoc(docs, {
|
|
229
297
|
title,
|
|
230
298
|
haystack: clip(`${title}\n${head.cwd}\n${workspaceLabel(head.cwd)}`),
|
|
@@ -243,23 +311,31 @@ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], bu
|
|
|
243
311
|
}
|
|
244
312
|
}
|
|
245
313
|
|
|
246
|
-
async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget:
|
|
314
|
+
async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: ScanBudget) {
|
|
247
315
|
const names = await loadCodexThreadNames(roots.codexSessions)
|
|
248
316
|
const pinned = await loadCodexPinnedIds(roots.codexSessions)
|
|
317
|
+
const candidates: Array<{ file: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
249
318
|
for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
|
|
250
|
-
if (budget.remaining <= 0) break
|
|
251
319
|
const st = await fileStat(file)
|
|
252
320
|
if (!st?.isFile) continue
|
|
253
|
-
|
|
321
|
+
candidates.push({ file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs })
|
|
322
|
+
}
|
|
323
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
324
|
+
for (const { file, mtimeMs, birthtimeMs } of candidates) {
|
|
325
|
+
if (budget.remaining <= 0 || budget.examined <= 0) break
|
|
326
|
+
budget.examined -= 1
|
|
254
327
|
const meta = await peekCodexMeta(file)
|
|
328
|
+
// Subagent rollouts are the Codex analogue of the keep-warm transcripts: 427 of 822
|
|
329
|
+
// on this machine per the 2026-08-18 census. Filtered before the doc budget is charged.
|
|
255
330
|
if (!meta || meta.subagent) continue
|
|
256
331
|
const name = file.split('/').pop() || file
|
|
257
332
|
const native = meta.id || name.slice(0, -6)
|
|
258
333
|
const thread = names.get(native) || ''
|
|
259
334
|
const title = thread || meta.title || 'Codex session'
|
|
260
335
|
if (isKeepWarmSessionTitle(title)) continue
|
|
336
|
+
budget.remaining -= 1
|
|
261
337
|
const users = await transcriptHaystack('codex', file)
|
|
262
|
-
const created = meta.created || createdFromCodexFilename(name) || isoFromMtime(
|
|
338
|
+
const created = meta.created || createdFromCodexFilename(name) || isoFromMtime(birthtimeMs)
|
|
263
339
|
pushDoc(docs, {
|
|
264
340
|
title,
|
|
265
341
|
haystack: clip(`${title}\n${thread}\n${meta.title}\n${meta.cwd}\n${users}`),
|
|
@@ -268,7 +344,7 @@ async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], bud
|
|
|
268
344
|
provider: 'codex',
|
|
269
345
|
display_label: title,
|
|
270
346
|
project: workspaceLabel(meta.cwd),
|
|
271
|
-
modified: isoFromMtime(
|
|
347
|
+
modified: isoFromMtime(mtimeMs),
|
|
272
348
|
created,
|
|
273
349
|
alive: false,
|
|
274
350
|
state: 'recent',
|
|
@@ -305,11 +381,12 @@ async function collectCursorDocs(
|
|
|
305
381
|
const ranked = [...byId.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
|
|
306
382
|
for (const [sessionDir, candidate] of ranked) {
|
|
307
383
|
if (budget.remaining <= 0) break
|
|
308
|
-
budget.remaining -= 1
|
|
309
384
|
const sidebar = composerNames.get(sessionDir) || ''
|
|
310
385
|
const users = await transcriptHaystack('cursor', candidate.file)
|
|
311
386
|
const title = sidebar || users.split('\n')[0] || 'Cursor session'
|
|
387
|
+
// Cursor already ranked, but it charged the budget before this filter too.
|
|
312
388
|
if (isKeepWarmSessionTitle(title)) continue
|
|
389
|
+
budget.remaining -= 1
|
|
313
390
|
pushDoc(docs, {
|
|
314
391
|
title,
|
|
315
392
|
haystack: clip(`${title}\n${sidebar}\n${candidate.project}\n${users}`),
|
|
@@ -335,8 +412,9 @@ export async function collectAgentSessionSearchDocs(
|
|
|
335
412
|
): Promise<SearchDoc[]> {
|
|
336
413
|
const docs: SearchDoc[] = []
|
|
337
414
|
const share = Math.max(1, Math.ceil(Math.min(cap, MAX_SCAN_FILES) / 3))
|
|
338
|
-
|
|
339
|
-
await
|
|
415
|
+
const examined = share * EXAMINE_MULTIPLE
|
|
416
|
+
await collectClaudeDocs(roots, docs, { remaining: share, examined })
|
|
417
|
+
await collectCodexDocs(roots, docs, { remaining: share, examined })
|
|
340
418
|
await collectCursorDocs(roots, docs, { remaining: share }, now)
|
|
341
419
|
docs.sort((a, b) => (b.row.modified || '').localeCompare(a.row.modified || ''))
|
|
342
420
|
return docs.slice(0, Math.max(1, Math.min(cap, MAX_SCAN_FILES)))
|
|
@@ -97,10 +97,32 @@ export function isScratchCursorProject(folder: string): boolean {
|
|
|
97
97
|
|
|
98
98
|
export const isSkippedCursorFolder = isScratchCursorProject
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Titles that belong to a MACHINE, not to a conversation Miles had.
|
|
102
|
+
*
|
|
103
|
+
* Measured 2026-08-18 by driving the real collector over the 1,296 Claude transcripts on
|
|
104
|
+
* this machine: of the 41 Claude docs it indexed, 28 were machine prompts -- 22 Slack
|
|
105
|
+
* Bridge proxy, 4 reply-with, 2 slack_search_users. Two thirds of Claude search was
|
|
106
|
+
* answering with scaffolding.
|
|
107
|
+
*
|
|
108
|
+
* Every `you are ...` prefix here is ANCHORED to a specific known caller. A bare
|
|
109
|
+
* `startsWith('you are')` would also swallow any human sentence beginning that way
|
|
110
|
+
* ("You are right that ..."), and the anchored pair covers the measured volume without
|
|
111
|
+
* that risk.
|
|
112
|
+
*
|
|
113
|
+
* Shared by all four search collectors AND the session-list path, so a title added here
|
|
114
|
+
* also stops appearing as a row in COS Control's session list. That is the intent.
|
|
115
|
+
*/
|
|
100
116
|
export function isKeepWarmSessionTitle(title: string): boolean {
|
|
101
117
|
const t = title.trim().toLowerCase()
|
|
102
118
|
if (t === 'ready') return true
|
|
103
|
-
|
|
119
|
+
if (t.startsWith('this is an automated local readiness check')) return true
|
|
120
|
+
if (t.startsWith('you are the cos slack bridge proxy')) return true
|
|
121
|
+
if (t.startsWith('you are a post-processing editor')) return true
|
|
122
|
+
if (t.startsWith('call mcp__claude_ai_slack__slack_search_users')) return true
|
|
123
|
+
if (/^reply with (exactly|the single word)\b/.test(t)) return true
|
|
124
|
+
if (t === 'say ok' || t === 'say: ok' || t === 'reply ok') return true
|
|
125
|
+
return false
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
/**
|
|
@@ -1063,9 +1085,29 @@ async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots):
|
|
|
1063
1085
|
const firstPrompt = await firstClaudeUserTitle(found)
|
|
1064
1086
|
const title = peek.customTitle ?? firstPrompt ?? row.display_label
|
|
1065
1087
|
const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
|
|
1088
|
+
// THE LIVE ROW'S `modified` IS A HEARTBEAT, NOT AN MTIME, AND IT GOES STALE.
|
|
1089
|
+
//
|
|
1090
|
+
// `liveClaudeRows` builds it from the peer registry's `lastActiveAt` (routes/
|
|
1091
|
+
// agent-sessions.ts:257). That value tracks the registry record, not the
|
|
1092
|
+
// transcript, so a session that is actively writing keeps reporting whenever the
|
|
1093
|
+
// registry last moved. Measured 2026-08-18 on three live sessions: the wire said
|
|
1094
|
+
// 55.3m / 407.7m / 435.0m while the transcript had been written 0.1m / 0.2m /
|
|
1095
|
+
// 5.1m earlier — under-reporting a live session by up to 7.2 hours.
|
|
1096
|
+
//
|
|
1097
|
+
// We are already holding the resolved transcript path and have already read it
|
|
1098
|
+
// twice, so the true mtime costs one stat. A failed stat keeps the heartbeat:
|
|
1099
|
+
// worse, but no worse than today.
|
|
1100
|
+
//
|
|
1101
|
+
// THAT FALLBACK IS DEFENSIVE AND UNREACHED BY THE SUITE. `if (!found) return row`
|
|
1102
|
+
// above already catches the missing-file case, so reaching `: row.modified` needs a
|
|
1103
|
+
// stat to fail on a file that was just resolved. A mutation of it survives; that is
|
|
1104
|
+
// recorded rather than papered over.
|
|
1105
|
+
const st = await fileStat(found)
|
|
1106
|
+
const modified = st?.isFile ? isoFromMtime(st.mtimeMs) : row.modified
|
|
1066
1107
|
return {
|
|
1067
1108
|
...row,
|
|
1068
1109
|
session_id: fullId,
|
|
1110
|
+
modified,
|
|
1069
1111
|
display_label: title || row.display_label || 'Claude session',
|
|
1070
1112
|
...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
|
|
1071
1113
|
}
|
|
@@ -194,6 +194,18 @@ export interface AttachedTurnFailureResult {
|
|
|
194
194
|
*/
|
|
195
195
|
detail: string | null
|
|
196
196
|
exitCode: number | null
|
|
197
|
+
/**
|
|
198
|
+
* Did `close`/`exit` actually fire, i.e. did the kernel reap the child?
|
|
199
|
+
*
|
|
200
|
+
* NOT DERIVABLE FROM `exitCode`. A child killed by a signal reports
|
|
201
|
+
* `code === null`, and the handlers below only assign `exitCode` for a numeric
|
|
202
|
+
* code — so the dominant timeout shape (SIGTERM, then SIGKILL) is reaped while
|
|
203
|
+
* leaving `exitCode` null. Deriving reaping from the code therefore reports
|
|
204
|
+
* "never reaped" for exactly the case a reader most needs to identify.
|
|
205
|
+
* Only the force-settle path, which fires when `close` never arrived, is
|
|
206
|
+
* genuinely unreaped.
|
|
207
|
+
*/
|
|
208
|
+
reaped: boolean
|
|
197
209
|
stderrClass: AttachedStderrClass
|
|
198
210
|
durationMs: number
|
|
199
211
|
}
|
|
@@ -745,6 +757,10 @@ function fail(
|
|
|
745
757
|
nativeThreadId: null,
|
|
746
758
|
returnedNativeId: null,
|
|
747
759
|
delivery,
|
|
760
|
+
// Conservative default: NOT OBSERVED reaped. Most `fail()` callers are
|
|
761
|
+
// `not_attempted` paths where no child exists, and the ones that do have a
|
|
762
|
+
// child override this from `settleFailure`.
|
|
763
|
+
reaped: false,
|
|
748
764
|
reason,
|
|
749
765
|
detail: null,
|
|
750
766
|
exitCode: null,
|
|
@@ -1051,6 +1067,10 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1051
1067
|
nativeThreadId,
|
|
1052
1068
|
returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
|
|
1053
1069
|
exitCode,
|
|
1070
|
+
// Every settle but the force-settle below is reached from `finishTerminal`,
|
|
1071
|
+
// which only runs from the `close`/`error` handlers — so the child was
|
|
1072
|
+
// reaped. The one exception overrides this explicitly.
|
|
1073
|
+
reaped: true,
|
|
1054
1074
|
stderrClass: classifyStderr(stderrSample),
|
|
1055
1075
|
durationMs: duration(),
|
|
1056
1076
|
...over,
|
|
@@ -1193,7 +1213,7 @@ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
|
|
|
1193
1213
|
// A child that survived SIGKILL cannot be reached from here, and
|
|
1194
1214
|
// blocking forever would wedge the coordinator and any Control drain
|
|
1195
1215
|
// behind it.
|
|
1196
|
-
settleFailure('timeout', { detail: 'unreaped' })
|
|
1216
|
+
settleFailure('timeout', { detail: 'unreaped', reaped: false })
|
|
1197
1217
|
}, FORCE_SETTLE_MS)
|
|
1198
1218
|
}, KILL_GRACE_MS)
|
|
1199
1219
|
}, timeoutMs)
|
|
@@ -34,6 +34,69 @@ export interface FenceRecord {
|
|
|
34
34
|
turnId: string
|
|
35
35
|
bindingId: string | null
|
|
36
36
|
fencedAt: number
|
|
37
|
+
|
|
38
|
+
// ── EVIDENCE (all optional, all DISK-ONLY) ────────────────
|
|
39
|
+
//
|
|
40
|
+
// WHY THIS EXISTS. Two plans designed an automatic fence resolver and both were
|
|
41
|
+
// rejected, the second because we have never observed a single fence. The
|
|
42
|
+
// distribution decides whether any automatic path is safe: if `timeout`
|
|
43
|
+
// dominates, the child had the full 21-minute budget to run tool calls before
|
|
44
|
+
// SIGKILL and re-delivery re-executes them, so no automatic clear is ever safe.
|
|
45
|
+
// If the child exited on its own with a known code, the footing is much better.
|
|
46
|
+
//
|
|
47
|
+
// EVERY FIELD IS OPTIONAL, and deliberately NOT added to `isFenceRecord`. That
|
|
48
|
+
// predicate is cast-based (`r is FenceRecord`), so a REQUIRED field would type as
|
|
49
|
+
// present while being undefined at runtime and tsc could not catch it; and
|
|
50
|
+
// extending the validator would reclassify existing rows as unrecognised, routing
|
|
51
|
+
// them into the preserved-but-inert pile and silently un-enforcing real fences.
|
|
52
|
+
//
|
|
53
|
+
// NONE OF THIS REACHES THE WIRE. The router contract carries no pid and no native
|
|
54
|
+
// thread id; `listFences()` already omits `bindingId` as precedent.
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The adapter's own verdict.
|
|
58
|
+
*
|
|
59
|
+
* REACHABILITY IS DECIDED BY `delivery === 'ambiguous'`, i.e. the stdin-write
|
|
60
|
+
* boundary — only a turn whose prompt bytes reached the child can fence. The
|
|
61
|
+
* reachable set is therefore NOT the full `AttachedTurnFailure` enum:
|
|
62
|
+
* timeout, provider_exit_nonzero, no_native_id_returned, native_id_mismatch,
|
|
63
|
+
* child_stdio_unavailable (detail `write_failed`), spawn_failed (ONLY via
|
|
64
|
+
* detail `child_error`; its other forms are `not_attempted` and never fence),
|
|
65
|
+
* plus two this route synthesises — `ok` (the adapter reported success with a
|
|
66
|
+
* non-delivered state, a contradiction) and `unreadable_result`.
|
|
67
|
+
* `adapter_internal_error` can NEVER fence: every emission site is
|
|
68
|
+
* `not_attempted` or guarded. `route_error` comes from the catch site.
|
|
69
|
+
*/
|
|
70
|
+
adapterReason?: string
|
|
71
|
+
/**
|
|
72
|
+
* WHICH fence site fired.
|
|
73
|
+
*
|
|
74
|
+
* `adapterReason` cannot substitute. The catch site inherits whatever the
|
|
75
|
+
* adapter last reported, so a route crash AFTER a clean delivery records
|
|
76
|
+
* `ok` — the single strongest reason NOT to re-deliver, which would read as
|
|
77
|
+
* "nothing went wrong". Only this field separates them.
|
|
78
|
+
*/
|
|
79
|
+
fenceSite?: 'ambiguous' | 'route_error'
|
|
80
|
+
/** Bounded self-authored discriminator, e.g. `unreaped`. Never provider output. */
|
|
81
|
+
adapterDetail?: string | null
|
|
82
|
+
exitCode?: number | null
|
|
83
|
+
/**
|
|
84
|
+
* Reported BY THE ADAPTER, never derived from `exitCode`.
|
|
85
|
+
*
|
|
86
|
+
* A signal-killed child reports `code === null`, and the adapter only assigns
|
|
87
|
+
* `exitCode` for a numeric code — so deriving this would record "never reaped"
|
|
88
|
+
* for the dominant timeout shape (SIGTERM then SIGKILL), which is the exact
|
|
89
|
+
* case this evidence exists to identify. Undefined when the adapter result was
|
|
90
|
+
* unreadable: absent, never a fabricated `false`.
|
|
91
|
+
*/
|
|
92
|
+
childReaped?: boolean
|
|
93
|
+
stderrClass?: string
|
|
94
|
+
/** How long the turn ran before it failed. A timeout is ~21 minutes of tool calls. */
|
|
95
|
+
durationMs?: number
|
|
96
|
+
/** Every child COS spawned for this turn, with its MEASURED start. Both are needed:
|
|
97
|
+
* a pid alone cannot be distinguished from a recycled one. An EMPTY list means no
|
|
98
|
+
* child was ever spawned, which no resolver may ever read as "nothing landed". */
|
|
99
|
+
spawns?: Array<{ pid: number; startMs: number }>
|
|
37
100
|
}
|
|
38
101
|
|
|
39
102
|
export function fencePath(): string {
|
|
@@ -839,6 +839,17 @@ export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
|
|
|
839
839
|
/** What a fence records about the turn that set it. */
|
|
840
840
|
export interface FenceEvidence {
|
|
841
841
|
provider: string
|
|
842
|
+
/** The adapter's verdict, or 'route_error' at the catch site. DISK-ONLY. */
|
|
843
|
+
adapterReason?: string
|
|
844
|
+
/** WHICH site fenced. `adapterReason` cannot substitute: the catch site inherits
|
|
845
|
+
* whatever the adapter reported, so a fence can read 'ok' there. DISK-ONLY. */
|
|
846
|
+
fenceSite?: 'ambiguous' | 'route_error'
|
|
847
|
+
adapterDetail?: string | null
|
|
848
|
+
exitCode?: number | null
|
|
849
|
+
childReaped?: boolean
|
|
850
|
+
stderrClass?: string
|
|
851
|
+
durationMs?: number
|
|
852
|
+
spawns?: Array<{ pid: number; startMs: number }>
|
|
842
853
|
/** The head BEFORE the ambiguous turn. Null ONLY when the failure happened
|
|
843
854
|
* before the head was read. */
|
|
844
855
|
headBefore: string | null
|
|
@@ -961,6 +972,14 @@ class TargetGuard {
|
|
|
961
972
|
turnId: evidence.turnId,
|
|
962
973
|
bindingId: evidence.bindingId,
|
|
963
974
|
fencedAt: evidence.now,
|
|
975
|
+
adapterReason: evidence.adapterReason,
|
|
976
|
+
fenceSite: evidence.fenceSite,
|
|
977
|
+
adapterDetail: evidence.adapterDetail,
|
|
978
|
+
exitCode: evidence.exitCode,
|
|
979
|
+
childReaped: evidence.childReaped,
|
|
980
|
+
stderrClass: evidence.stderrClass,
|
|
981
|
+
durationMs: evidence.durationMs,
|
|
982
|
+
spawns: evidence.spawns,
|
|
964
983
|
})
|
|
965
984
|
this.persistFences([...this.fences.values()])
|
|
966
985
|
}
|
|
@@ -1389,7 +1408,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1389
1408
|
res.status(status).json({ released: false, reason: outcome.reason })
|
|
1390
1409
|
return
|
|
1391
1410
|
}
|
|
1392
|
-
|
|
1411
|
+
// THE EVIDENCE DIES WITH THE ROW. `releaseFence` deletes it, and the realistic
|
|
1412
|
+
// sequence is: first fence ever lands -> Control's card appears -> it is
|
|
1413
|
+
// released -> the distribution this evidence exists to collect is gone. So the
|
|
1414
|
+
// release line carries the whole record, not just its identity.
|
|
1415
|
+
const ev = outcome.row
|
|
1416
|
+
console.warn(`[agent-session-bindings] fence RELEASED by operator target=${target} provider=${ev.provider} fencedAt=${ev.fencedAt} fenceSite=${ev.fenceSite ?? 'unknown'} adapterReason=${ev.adapterReason ?? 'unknown'} detail=${ev.adapterDetail ?? 'none'} exitCode=${ev.exitCode ?? 'null'} childReaped=${ev.childReaped ?? 'unknown'} stderrClass=${ev.stderrClass ?? 'none'} durationMs=${ev.durationMs ?? 'unknown'} spawnCount=${ev.spawns?.length ?? 0}`)
|
|
1393
1417
|
res.json({ released: true, target, provider: outcome.row.provider })
|
|
1394
1418
|
})
|
|
1395
1419
|
|
|
@@ -1809,7 +1833,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1809
1833
|
let fenceProvider = ''
|
|
1810
1834
|
let preTurnHeadDigest: string | null = null
|
|
1811
1835
|
/** Children the adapter reported, released in the finally. */
|
|
1812
|
-
|
|
1836
|
+
// PAIRS, not bare pids. A pid alone cannot be told apart from a recycled one,
|
|
1837
|
+
// and `startMs` is measured in the onSpawn closure and was previously discarded.
|
|
1838
|
+
const recordedPids: Array<{ pid: number; startMs: number }> = []
|
|
1839
|
+
// Hoisted so BOTH fence sites can record what the adapter actually reported.
|
|
1840
|
+
// `result` is scoped inside the try; the ambiguous site sits after the catch.
|
|
1841
|
+
// NARROW ON PURPOSE. `Partial<FenceEvidence>` would permit provider/headBefore/
|
|
1842
|
+
// turnId/bindingId/now, and the spread sits AFTER them at both fence sites — so
|
|
1843
|
+
// a future field could silently overwrite the fence's identity. The adapter
|
|
1844
|
+
// result literally carries a `provider`, which would write null and fail
|
|
1845
|
+
// `isFenceRecord` on the next read, silently un-enforcing the fence.
|
|
1846
|
+
let adapterEvidence: Partial<Pick<FenceEvidence,
|
|
1847
|
+
'adapterReason' | 'adapterDetail' | 'exitCode' | 'childReaped' | 'stderrClass' | 'durationMs'>> = {}
|
|
1813
1848
|
let pinnedBindingId: string | null = null
|
|
1814
1849
|
let requestNow = 0
|
|
1815
1850
|
/**
|
|
@@ -2095,10 +2130,35 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2095
2130
|
// before the prompt rather than deliver a turn that poisons the next
|
|
2096
2131
|
// occupancy check.
|
|
2097
2132
|
if (outcome !== 'recorded') return false
|
|
2098
|
-
recordedPids.push(pid)
|
|
2133
|
+
recordedPids.push({ pid, startMs })
|
|
2099
2134
|
return true
|
|
2100
2135
|
},
|
|
2101
2136
|
})
|
|
2137
|
+
// Read the adapter's OWN account before it goes out of scope. This is the
|
|
2138
|
+
// whole point of the evidence work: `classifyDelivery` collapses six distinct
|
|
2139
|
+
// failures into one word, and the difference between them is what decides
|
|
2140
|
+
// whether an automatic resolver could ever be safe. A 21-minute `timeout`
|
|
2141
|
+
// means the child ran tool calls; a `provider_exit_nonzero` with a code means
|
|
2142
|
+
// it exited on its own. Defensive reads — the adapter is injected in tests.
|
|
2143
|
+
// AN UNREADABLE RESULT RECORDS NOTHING, not zeroes. Reading `{}` and
|
|
2144
|
+
// deriving `exitCode: null, childReaped: false` states two facts about a
|
|
2145
|
+
// child nothing is known about, and writes them indistinguishably from a
|
|
2146
|
+
// confirmed-unreaped timeout — corrupting the one discriminator this
|
|
2147
|
+
// evidence exists to establish.
|
|
2148
|
+
const r = (result !== null && typeof result === 'object')
|
|
2149
|
+
? result as Record<string, unknown>
|
|
2150
|
+
: null
|
|
2151
|
+
adapterEvidence = r === null ? {} : {
|
|
2152
|
+
adapterReason: typeof r.reason === 'string' ? r.reason : (r.ok === true ? 'ok' : undefined),
|
|
2153
|
+
adapterDetail: typeof r.detail === 'string' ? r.detail : null,
|
|
2154
|
+
exitCode: typeof r.exitCode === 'number' ? r.exitCode : null,
|
|
2155
|
+
// FROM THE ADAPTER, never derived from exitCode. A signal-killed child
|
|
2156
|
+
// reports `code === null`, so deriving it would report "never reaped" for
|
|
2157
|
+
// the dominant timeout shape — backwards for the decision this informs.
|
|
2158
|
+
childReaped: typeof r.reaped === 'boolean' ? r.reaped : undefined,
|
|
2159
|
+
stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
|
|
2160
|
+
durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
|
|
2161
|
+
}
|
|
2102
2162
|
delivery = classifyDelivery(result)
|
|
2103
2163
|
} catch (error) {
|
|
2104
2164
|
console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
|
|
@@ -2116,18 +2176,30 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2116
2176
|
// Fenced under its own reason, not this turn's: `delivery_ambiguous`
|
|
2117
2177
|
// describes what happened to THIS request, while a later caller needs to
|
|
2118
2178
|
// be told the thread is shut and why it must be inspected first.
|
|
2179
|
+
// ONE resolved value for the record AND the log. The previous release
|
|
2180
|
+
// fixed exactly this contradiction at the other fence site and this one
|
|
2181
|
+
// re-committed it: the record said `unreadable_result` while the log said
|
|
2182
|
+
// `unknown`, so an operator grepping for the sentinel found nothing.
|
|
2183
|
+
const recordedReason = adapterEvidence.adapterReason ?? 'unreadable_result'
|
|
2119
2184
|
guard.fence(key, 'native_target_fenced', {
|
|
2120
2185
|
provider: binding.provider,
|
|
2121
2186
|
headBefore: head.digest,
|
|
2122
2187
|
turnId,
|
|
2123
2188
|
bindingId,
|
|
2124
2189
|
now: Date.now(),
|
|
2190
|
+
...adapterEvidence,
|
|
2191
|
+
// NEVER blank. An unreadable adapter result and "nobody recorded it" are
|
|
2192
|
+
// different facts and a missing field cannot tell them apart -- which is
|
|
2193
|
+
// the whole reason this evidence exists.
|
|
2194
|
+
adapterReason: recordedReason,
|
|
2195
|
+
fenceSite: 'ambiguous',
|
|
2196
|
+
spawns: [...recordedPids],
|
|
2125
2197
|
})
|
|
2126
2198
|
// A fence shuts a thread until a human acts, and until now it wrote NO log
|
|
2127
2199
|
// line at either site — so a fenced thread was discoverable only by trying
|
|
2128
2200
|
// to use it (Miles, 2026-08-18). Never log `key`: it embeds the private
|
|
2129
2201
|
// native thread id, which this router does not emit anywhere.
|
|
2130
|
-
console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest}`)
|
|
2202
|
+
console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=${recordedReason} detail=${adapterEvidence.adapterDetail ?? 'none'} exitCode=${adapterEvidence.exitCode ?? 'null'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} stderrClass=${adapterEvidence.stderrClass ?? 'none'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
|
|
2131
2203
|
return reportAmbiguous()
|
|
2132
2204
|
}
|
|
2133
2205
|
|
|
@@ -2164,20 +2236,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2164
2236
|
turnId,
|
|
2165
2237
|
bindingId: null,
|
|
2166
2238
|
now: Date.now(),
|
|
2239
|
+
// A bug in THIS route, not a provider outcome. Named rather than left
|
|
2240
|
+
// blank so a later reader can tell "the adapter reported nothing" apart
|
|
2241
|
+
// from "nobody recorded it". Any adapter evidence captured before the
|
|
2242
|
+
// throw is still carried.
|
|
2243
|
+
...adapterEvidence,
|
|
2244
|
+
adapterReason: adapterEvidence.adapterReason ?? 'route_error',
|
|
2245
|
+
fenceSite: 'route_error',
|
|
2246
|
+
spawns: [...recordedPids],
|
|
2167
2247
|
})
|
|
2168
2248
|
// `head` is scoped to the try, so `preTurnHeadDigest` is hoisted to the
|
|
2169
2249
|
// handler specifically to reach this site. It is null ONLY when the throw
|
|
2170
2250
|
// happened before the head was read. An earlier version of this line
|
|
2171
2251
|
// hardcoded `unavailable` and so contradicted the record it had just
|
|
2172
2252
|
// written — an operator would read "no baseline" off a fence that has one.
|
|
2173
|
-
console.warn(`[agent-session-bindings] fence set site=route_error target=${opaqueRevision(claimedKey)} turnId=${turnId} headBefore=${preTurnHeadDigest ?? 'unavailable'}`)
|
|
2253
|
+
console.warn(`[agent-session-bindings] fence set site=route_error target=${opaqueRevision(claimedKey)} turnId=${turnId} headBefore=${preTurnHeadDigest ?? 'unavailable'} adapterReason=${adapterEvidence.adapterReason ?? 'route_error'} detail=${adapterEvidence.adapterDetail ?? 'none'} exitCode=${adapterEvidence.exitCode ?? 'null'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} stderrClass=${adapterEvidence.stderrClass ?? 'none'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
|
|
2174
2254
|
}
|
|
2175
2255
|
reportAmbiguous()
|
|
2176
2256
|
} else {
|
|
2177
2257
|
refuseTurn('turn_failed')
|
|
2178
2258
|
}
|
|
2179
2259
|
} finally {
|
|
2180
|
-
for (const pid of recordedPids) {
|
|
2260
|
+
for (const { pid } of recordedPids) {
|
|
2181
2261
|
try {
|
|
2182
2262
|
ownership.release(pid)
|
|
2183
2263
|
} catch (error) {
|