@theronap/agnoclast-mcp 0.9.187 → 0.9.188
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/lib/diagnose.mjs +32 -4
- package/lib/report_spool.mjs +92 -14
- package/lib/server.mjs +9 -4
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -485,7 +485,8 @@ async function fetchCortexOnce(url, opts = {}, policy = {}) {
|
|
|
485
485
|
// app answered in JSON, the server already knows about (Sentry, §7), and a 429 is our own limiter
|
|
486
486
|
// telling this seat to slow down — filing about it would be the seat arguing with the limiter.
|
|
487
487
|
//
|
|
488
|
-
// connect / DNS
|
|
488
|
+
// connect / DNS → outage (failure_class 'connect')
|
|
489
|
+
// timeout → outage (failure_class 'timeout' — it ANSWERED too slowly, it did not refuse)
|
|
489
490
|
// non-JSON 4xx/5xx (not 401, not 429) → outage (failure_class 'infra' — firewall / protection)
|
|
490
491
|
// 503 → outage (failure_class 'refused', or 'infra' when non-JSON)
|
|
491
492
|
// 429, JSON app errors, 401 → never
|
|
@@ -501,11 +502,19 @@ const toolContext = new AsyncLocalStorage()
|
|
|
501
502
|
/** Run `fn` with `tool` as the name any outage filed during it is attributed to (server.mjs wraps every tool). */
|
|
502
503
|
export const runWithTool = (tool, fn) => toolContext.run(tool, fn)
|
|
503
504
|
|
|
504
|
-
// For callers outside a tool call (hooks, doctor). Unknown paths are 'other' — the server
|
|
505
|
+
// For callers outside a tool call (hooks, doctor, the heartbeat). Unknown paths are 'other' — the server
|
|
506
|
+
// coerces too.
|
|
507
|
+
//
|
|
508
|
+
// ⚠ 'other' IS WHERE A REPORT GOES TO BE UNREADABLE. The first ×3 `other` timeout (2026-09-28) took an
|
|
509
|
+
// agent a transcript-and-log dig to identify as the session-ping heartbeat, because every call made
|
|
510
|
+
// outside a tool — the heartbeat, capture, the arrivals hook, skill sync — landed in the same bucket.
|
|
511
|
+
// Each now carries a name the server's KNOWN_TOOLS accepts (web/lib/engine/bug_reports.ts).
|
|
505
512
|
const PATH_TOOLS = {
|
|
506
513
|
'mcp-context': 'my_context', 'session-context': 'session_context', 'grep': 'grep', 'search': 'search_org',
|
|
507
514
|
'brain/page': 'read_page', 'brain/author': 'author', 'brain/edit-page': 'edit_page', 'records/capture': 'capture_record',
|
|
508
515
|
'intake/claim': 'intake_claim', 'intake/materialize': 'intake_materialize', 'staged': 'staged_records', 'brains': 'my_brains',
|
|
516
|
+
'session-ping': 'session_ping', 'session-signals': 'session_signals', 'skills': 'skills',
|
|
517
|
+
'ingest': 'capture', 'node-types': 'capture', 'obligations/candidates': 'capture',
|
|
509
518
|
}
|
|
510
519
|
|
|
511
520
|
/** PURE. The tool an outage on `url` belongs to: the running tool if known, else a path mapping, else 'other'. */
|
|
@@ -528,7 +537,9 @@ export function outageReport(outcome, { tool, requestId = null, version = CLIENT
|
|
|
528
537
|
const base = { kind: 'outage', tool: tool ?? 'other', request_id: requestId ?? null, client_version: version ?? null }
|
|
529
538
|
if (outcome.thrownClass) {
|
|
530
539
|
if (outcome.thrownClass === 'connect') return { ...base, status: null, error_code: null, failure_class: 'connect' }
|
|
531
|
-
|
|
540
|
+
// A timeout is its own class: the server was reached and was SLOW. Filing it as 'connect' told the
|
|
541
|
+
// reader "could not reach" about a request that may well have completed after we gave up.
|
|
542
|
+
if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'timeout' }
|
|
532
543
|
return null
|
|
533
544
|
}
|
|
534
545
|
const { status, contentType } = outcome
|
|
@@ -606,20 +617,36 @@ function triggerFlush(url, authorization) {
|
|
|
606
617
|
/** Tests: await the in-flight flush, if any. */
|
|
607
618
|
export const pendingFlush = () => flushing
|
|
608
619
|
|
|
620
|
+
// ⚠ A HEARTBEAT IS A NOISY OUTAGE SENSOR. session-ping fires every 60s per open session, best-effort,
|
|
621
|
+
// never retried after a timeout — so one slow request on one instance filed an outage, and two MCP
|
|
622
|
+
// servers flushing the same entry counted it twice (2026-09-28: ×3 reports for two failures, laptop
|
|
623
|
+
// awake and online both times). A missed beat is harmless by design; the next one refreshes presence.
|
|
624
|
+
// So a heartbeat files only when it has failed this many times IN A ROW — a real outage does that, a
|
|
625
|
+
// blip does not.
|
|
626
|
+
export const HEARTBEAT_OUTAGE_AFTER = 3
|
|
627
|
+
/** PURE. The fetchCortex policy for a heartbeat that has already failed `consecutiveFailures` times in a row. */
|
|
628
|
+
export function heartbeatPolicy(consecutiveFailures) {
|
|
629
|
+
return { fileOutage: consecutiveFailures + 1 >= HEARTBEAT_OUTAGE_AFTER }
|
|
630
|
+
}
|
|
631
|
+
|
|
609
632
|
/**
|
|
610
633
|
* fetch with the D9 retry policy (see fetchCortexOnce), plus the ADR-0070 outage hook. Returns the
|
|
611
634
|
* Response (possibly a non-OK one, after retries are exhausted or refused); throws a clear "Could not
|
|
612
635
|
* reach" error when no response was ever obtained.
|
|
613
636
|
*
|
|
614
637
|
* policy.skipAutofile never auto-file this call's failure, and never flush from it (the report POSTs).
|
|
638
|
+
* policy.fileOutage false = do not file THIS call's failure, but DO still flush the spool on success.
|
|
639
|
+
* Distinct from skipAutofile on purpose: the heartbeat is the call most likely to
|
|
640
|
+
* be the next 2xx after an outage, so it must keep carrying the spool out.
|
|
615
641
|
*/
|
|
616
642
|
export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
617
643
|
const hook = !policy.skipAutofile && autofileEnabled() && authOf(opts)
|
|
644
|
+
const file = hook && policy.fileOutage !== false
|
|
618
645
|
let res
|
|
619
646
|
try {
|
|
620
647
|
res = await fetchCortexOnce(url, opts, policy)
|
|
621
648
|
} catch (e) {
|
|
622
|
-
if (
|
|
649
|
+
if (file && e?.retryClass) {
|
|
623
650
|
const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
|
|
624
651
|
// Awaited, not fire-and-forget: hook processes are short-lived and would exit before a detached
|
|
625
652
|
// probe answered. Bounded by REACHABILITY_TIMEOUT_MS, and only on a path that is already failing.
|
|
@@ -629,6 +656,7 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
|
629
656
|
}
|
|
630
657
|
if (!hook) return res
|
|
631
658
|
if (res.ok) { void triggerFlush(url, authOf(opts)); return res }
|
|
659
|
+
if (!file) return res
|
|
632
660
|
try {
|
|
633
661
|
const contentType = res.headers.get('content-type')
|
|
634
662
|
let errorCode = null
|
package/lib/report_spool.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// ⚠ STRUCTURED FIELDS ONLY. Nothing here ever holds an error MESSAGE — classify()'s text carries brain
|
|
20
20
|
// names and page titles. Only an interactive, user-approved report_issue may carry summary/expected.
|
|
21
21
|
|
|
22
|
-
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, openSync, closeSync } from 'node:fs'
|
|
23
23
|
import { homedir, tmpdir } from 'node:os'
|
|
24
24
|
import { join } from 'node:path'
|
|
25
25
|
import { createHash, randomBytes } from 'node:crypto'
|
|
@@ -81,6 +81,32 @@ export const readDropped = (dir = spoolDir(), now = Date.now()) => ledgerRead(di
|
|
|
81
81
|
|
|
82
82
|
const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1, reason })
|
|
83
83
|
|
|
84
|
+
// ⚠ ONE WRITER PER KEY AT A TIME. spoolReport, the flush claim and releaseClaim each READ an entry and
|
|
85
|
+
// then WRITE it. Without a lock, a spoolReport that read `<key>.json` just before a flush renamed it
|
|
86
|
+
// away would write the old count back into a fresh file — re-sending what had just been sent — and a
|
|
87
|
+
// releaseClaim racing a spoolReport could overwrite a repeat (Codex review of the claim design,
|
|
88
|
+
// 2026-09-29). An O_EXCL lock file per key serialises those three; the POST itself stays OUTSIDE it,
|
|
89
|
+
// so a slow server never holds a writer up. A lock older than LOCK_STALE_MS belongs to a dead process.
|
|
90
|
+
const LOCK_STALE_MS = 5_000
|
|
91
|
+
const LOCK_WAIT_MS = 250
|
|
92
|
+
function withKeyLock(dir, key, fn) {
|
|
93
|
+
const lock = join(dir, `_lock.${key}`)
|
|
94
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
95
|
+
for (;;) {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
98
|
+
closeSync(openSync(lock, 'wx', 0o600))
|
|
99
|
+
break
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e?.code !== 'EEXIST') return undefined
|
|
102
|
+
try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue } } catch { continue }
|
|
103
|
+
if (Date.now() > deadline) return undefined // contended: the caller skips, it never blocks a session
|
|
104
|
+
const until = Date.now() + 5; while (Date.now() < until) { /* brief spin: these sections are sub-ms */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try { return fn() } finally { try { unlinkSync(lock) } catch { /* already gone */ } }
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
/**
|
|
85
111
|
* Spool one report. A repeat of the same signature increments `count`; a NEW signature past the cap
|
|
86
112
|
* evicts the oldest (by last_seen) into the drop ledger. Returns the entry written, or null. Never throws.
|
|
@@ -88,6 +114,12 @@ const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.
|
|
|
88
114
|
export function spoolReport(report, { dir = spoolDir(), now = Date.now() } = {}) {
|
|
89
115
|
try {
|
|
90
116
|
const key = reportKey(report)
|
|
117
|
+
return withKeyLock(dir, key, () => spoolLocked(report, key, dir, now)) ?? null
|
|
118
|
+
} catch { return null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function spoolLocked(report, key, dir, now) {
|
|
122
|
+
try {
|
|
91
123
|
const cur = readJson(join(dir, `${key}.json`), null)
|
|
92
124
|
const entry = cur?.key
|
|
93
125
|
? { ...cur, ...report, key, rev: randomBytes(6).toString('hex'), count: (cur.count ?? 1) + (report.count ?? 1), last_seen: now }
|
|
@@ -120,31 +152,77 @@ export function wireBody(e) {
|
|
|
120
152
|
return body
|
|
121
153
|
}
|
|
122
154
|
|
|
155
|
+
// ⚠ CLAIM BEFORE SENDING. Every MCP server and hook process flushes after its own next 2xx, and nothing
|
|
156
|
+
// stopped two of them reading the same entry and both POSTing it — the server dedupes onto one open row
|
|
157
|
+
// per signature and ADDS the count, so one failure was reported twice. Measured 2026-09-28: two POSTs of
|
|
158
|
+
// one entry 183 ms apart, which is how two heartbeat timeouts became "×3". A rename is atomic, so of any
|
|
159
|
+
// number of concurrent flushes exactly one takes a given entry; the rest skip it. While it is claimed, a
|
|
160
|
+
// repeat of the same failure lands in a FRESH <key>.json, so nothing spooled mid-send is lost or re-sent.
|
|
161
|
+
const CLAIM_MARK = '.json.sending.'
|
|
162
|
+
export const STALE_CLAIM_MS = 10 * 60_000
|
|
163
|
+
|
|
164
|
+
/** Put a claimed entry back (a send that must be retried, or a claim its process abandoned), merging any
|
|
165
|
+
* repeats spooled while it was out. Never throws. */
|
|
166
|
+
function releaseClaim(dir, claimedPath, entry, { attempted }) {
|
|
167
|
+
withKeyLock(dir, entry.key, () => releaseLocked(dir, claimedPath, entry, { attempted }))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function releaseLocked(dir, claimedPath, entry, { attempted }) {
|
|
171
|
+
try {
|
|
172
|
+
const file = join(dir, `${entry.key}.json`)
|
|
173
|
+
const fresh = readJson(file, null)
|
|
174
|
+
const attempts = (entry.attempts ?? 0) + (attempted ? 1 : 0)
|
|
175
|
+
const merged = fresh?.key
|
|
176
|
+
? { ...fresh, count: (fresh.count ?? 1) + (entry.count ?? 1), first_seen: Math.min(fresh.first_seen ?? Infinity, entry.first_seen ?? Infinity), attempts }
|
|
177
|
+
: { ...entry, attempts }
|
|
178
|
+
atomicWrite(dir, `${entry.key}.json`, merged)
|
|
179
|
+
unlinkSync(claimedPath)
|
|
180
|
+
} catch { /* the claim file stays and is recovered as stale */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Claims older than STALE_CLAIM_MS belong to a process that died mid-send: return them to the spool. */
|
|
184
|
+
function recoverStaleClaims(dir, now) {
|
|
185
|
+
let names
|
|
186
|
+
try { names = readdirSync(dir) } catch { return }
|
|
187
|
+
for (const n of names) {
|
|
188
|
+
if (!n.includes(CLAIM_MARK)) continue
|
|
189
|
+
const p = join(dir, n)
|
|
190
|
+
try {
|
|
191
|
+
if (now - statSync(p).mtimeMs < STALE_CLAIM_MS) continue
|
|
192
|
+
const e = readJson(p, null)
|
|
193
|
+
if (e?.key) releaseClaim(dir, p, e, { attempted: false })
|
|
194
|
+
else unlinkSync(p)
|
|
195
|
+
} catch { /* raced with another recoverer */ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
123
199
|
/**
|
|
124
|
-
* Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it (
|
|
125
|
-
*
|
|
126
|
-
*
|
|
200
|
+
* Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it; a final 4xx (not 429)
|
|
201
|
+
* moves it to the drop ledger; 429/5xx/network put it back with attempts+1. Each entry is CLAIMED first
|
|
202
|
+
* (see CLAIM_MARK), so concurrent flushes never send one entry twice. Returns { sent, kept, dropped }.
|
|
127
203
|
*/
|
|
128
204
|
export async function flush(postFn, { dir = spoolDir(), now = Date.now } = {}) {
|
|
129
205
|
const out = { sent: 0, kept: 0, dropped: 0 }
|
|
206
|
+
recoverStaleClaims(dir, now())
|
|
130
207
|
for (const e of readEntries(dir)) {
|
|
131
|
-
let status = 0
|
|
132
|
-
try { status = Number((await postFn(wireBody(e)))?.status) || 0 } catch { status = 0 }
|
|
133
208
|
const file = join(dir, `${e.key}.json`)
|
|
134
|
-
const
|
|
209
|
+
const claimed = `${file}${CLAIM_MARK.slice('.json'.length)}${process.pid}.${randomBytes(3).toString('hex')}`
|
|
210
|
+
const took = withKeyLock(dir, e.key, () => { try { renameSync(file, claimed); return true } catch { return false } })
|
|
211
|
+
if (!took) continue // another flush took it, or a writer holds the key right now — next flush gets it
|
|
212
|
+
const mine = readJson(claimed, null) ?? e // what we actually own — repeats may have landed since readEntries
|
|
213
|
+
let status = 0
|
|
214
|
+
try { status = Number((await postFn(wireBody(mine)))?.status) || 0 } catch { status = 0 }
|
|
135
215
|
if (status >= 200 && status < 300) {
|
|
136
216
|
out.sent++
|
|
137
|
-
ledgerAppend(dir, '_filed.json', { at: now(), kind:
|
|
138
|
-
|
|
139
|
-
atomicWrite(dir, `${e.key}.json`, { ...cur, count: cur.count - (e.count ?? 1) })
|
|
140
|
-
} else { try { unlinkSync(file) } catch { /* raced */ } }
|
|
217
|
+
ledgerAppend(dir, '_filed.json', { at: now(), kind: mine.kind, tool: mine.tool, status: mine.status ?? null, count: mine.count ?? 1 }, now())
|
|
218
|
+
try { unlinkSync(claimed) } catch { /* raced */ }
|
|
141
219
|
} else if (status >= 400 && status < 500 && status !== 429) {
|
|
142
220
|
out.dropped++
|
|
143
|
-
try { unlinkSync(
|
|
144
|
-
ledgerAppend(dir, '_dropped.json', dropRow(
|
|
221
|
+
try { unlinkSync(claimed) } catch { /* raced */ }
|
|
222
|
+
ledgerAppend(dir, '_dropped.json', dropRow(mine, `rejected ${status}`, now()), now())
|
|
145
223
|
} else {
|
|
146
224
|
out.kept++
|
|
147
|
-
|
|
225
|
+
releaseClaim(dir, claimed, mine, { attempted: true })
|
|
148
226
|
}
|
|
149
227
|
}
|
|
150
228
|
return out
|
package/lib/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
|
|
|
5
5
|
import { homedir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
|
-
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool } from './diagnose.mjs'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
9
9
|
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
10
10
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
11
11
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
@@ -253,9 +253,12 @@ export async function runServer(version) {
|
|
|
253
253
|
function currentAuthored() {
|
|
254
254
|
try { return workedFiles ? workedFiles.authored(100) : [] } catch { return [] }
|
|
255
255
|
}
|
|
256
|
+
// Consecutive heartbeat failures. A lone slow beat is not an outage (see HEARTBEAT_OUTAGE_AFTER);
|
|
257
|
+
// three in a row is, and that beat files through fetchCortex's normal path, probe included.
|
|
258
|
+
let pingFailures = 0
|
|
256
259
|
async function pingSession() {
|
|
257
260
|
try {
|
|
258
|
-
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
261
|
+
const res = await fetchCortex(`${BASE}/api/session-ping`, {
|
|
259
262
|
method: 'POST',
|
|
260
263
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
261
264
|
// mcpVersion (ADR-0033 T9): the fleet currently has NO way to report which client build a
|
|
@@ -286,8 +289,10 @@ export async function runServer(version) {
|
|
|
286
289
|
// YOURS; a shared changed file is not (see authoredInCall in working_files.mjs).
|
|
287
290
|
authored: currentAuthored(),
|
|
288
291
|
}),
|
|
289
|
-
})
|
|
290
|
-
|
|
292
|
+
}, heartbeatPolicy(pingFailures))
|
|
293
|
+
pingFailures = res.ok ? 0 : pingFailures + 1
|
|
294
|
+
try { await res.body?.cancel() } catch { /* nothing to drain */ }
|
|
295
|
+
} catch { pingFailures++ /* best-effort heartbeat — never disrupt the session */ }
|
|
291
296
|
}
|
|
292
297
|
void pingSession()
|
|
293
298
|
const _hb = setInterval(() => void pingSession(), 60_000)
|
package/package.json
CHANGED