@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 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 / timeout → outage (failure_class 'connect')
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 coerces too.
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
- if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'connect' }
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 (hook && e?.retryClass) {
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
@@ -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 (any repeats spooled
125
- * meanwhile stay); a final 4xx (not 429) moves it to the drop ledger; 429/5xx/network keep it with
126
- * attempts+1. Returns { sent, kept, dropped }.
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 cur = readJson(file, null)
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: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1 }, now())
138
- if (cur?.rev && cur.rev !== e.rev && (cur.count ?? 1) > (e.count ?? 1)) {
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(file) } catch { /* raced */ }
144
- ledgerAppend(dir, '_dropped.json', dropRow(e, `rejected ${status}`, now()), now())
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
- if (cur?.key) atomicWrite(dir, `${e.key}.json`, { ...cur, attempts: (cur.attempts ?? 0) + 1 })
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
- } catch { /* best-effort heartbeat — never disrupt the session */ }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.187",
3
+ "version": "0.9.188",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {