@gotcos/glasses-server 6.36.8 → 6.36.10
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
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.36.10
|
|
4
|
+
- **A fenced thread had no exit and left no trace.** An ambiguous delivery fences the
|
|
5
|
+
target so a prompt cannot be double-delivered into a real conversation — that is
|
|
6
|
+
correct and stays. Everything around it was wrong: the fence lived in a process-local
|
|
7
|
+
Map, wrote no log line at either site, had no list, and had no release. It was
|
|
8
|
+
discoverable only by being refused, and the only thing that cleared it was a restart.
|
|
9
|
+
- **Now listable and releasable without a restart.** `GET /api/agent-sessions/fences`
|
|
10
|
+
lists them; `POST /api/agent-sessions/fences/release` clears one. Addressed by DIGEST,
|
|
11
|
+
never by raw target key — the key embeds the private native thread id. Fails closed:
|
|
12
|
+
without `confirm: true` it returns 400 with a preview of what would be reopened. That
|
|
13
|
+
confirmation is a deliberate second call, NOT proof a human looked — the API token is
|
|
14
|
+
shared by the phone, the lens and every COS agent session, so nothing is structurally
|
|
15
|
+
prevented from asserting it. The comment says so rather than overclaiming.
|
|
16
|
+
- **Durable storage ships INERT, behind `COS_THREAD_FENCE_DURABLE=1`, default off.**
|
|
17
|
+
Persisting the fence is the right direction, but durability without a reachable
|
|
18
|
+
release is a regression, not a fix: today "Restart Server" in COS Control clears a
|
|
19
|
+
fence, and making it survive restarts with no operator surface in Control would turn
|
|
20
|
+
an 8-second annoyance into a permanently dead thread needing a terminal. The flag
|
|
21
|
+
flips on when COS Control has a Fences card. The routes above already remove the
|
|
22
|
+
restart from the recovery path.
|
|
23
|
+
- **A write can never erase what it could not read.** `TargetGuard` hydrates only the
|
|
24
|
+
rows it understood and saves its map wholesale, so a single unrecognised row — a
|
|
25
|
+
newer schema, a partial write, one bad field — would otherwise be erased by the next
|
|
26
|
+
fence on an unrelated thread, silently reopening every other fenced thread. Writes now
|
|
27
|
+
merge unrecognised rows back through. A corrupt file is quarantined to
|
|
28
|
+
`.corrupt-<ts>` rather than dropped, and uses `durableAtomicWriteFileSync` (fsync of
|
|
29
|
+
bytes, metadata and directory; randomized exclusive temp name) rather than the
|
|
30
|
+
lightweight cache writer.
|
|
31
|
+
- **A release is persisted before it is reported.** Mutating memory first and reporting
|
|
32
|
+
success meant an operator could be told a thread was open, write to it, and find it
|
|
33
|
+
fenced again after the next restart with no record of why. A failed write now returns
|
|
34
|
+
500 `persist_failed` and the fence holds. `GET /fences` reports `degraded` when the
|
|
35
|
+
last write failed — a memory-only fence set is otherwise indistinguishable from a
|
|
36
|
+
durable one until the process restarts.
|
|
37
|
+
- **Visible.** Breadcrumbs at both fence-set sites (tagged `ambiguous` vs `route_error`)
|
|
38
|
+
and both fence-hit routes (turn, attach). No raw target key in any of them. The
|
|
39
|
+
route_error line reports the hoisted pre-turn head rather than hardcoding
|
|
40
|
+
`unavailable`, which contradicted the record it had just written.
|
|
41
|
+
- **Known, not fixed here:** releasing a fence does not by itself make the thread
|
|
42
|
+
attachable — the turn that fenced it left a binding holding the target for its
|
|
43
|
+
30-minute TTL, so the next attach refuses `native_target_busy`. There is no detach
|
|
44
|
+
route. `/attachability` still does not consult the fence, so the lens menu renders
|
|
45
|
+
Continue enabled on a fenced thread; the refusal is honest, the menu is not yet.
|
|
46
|
+
- **Coverage.** 11 mutations against the new guards fail the suite, including all four
|
|
47
|
+
that survived the first QA pass (the release handle check, and the provider/reason/
|
|
48
|
+
fencedAt row validators). One documented survivor remains: removing the write-once
|
|
49
|
+
guard on `fence()`, which no route can reach because both fence sites sit inside the
|
|
50
|
+
`tryClaim` section. The code says so at the call site.
|
|
51
|
+
|
|
52
|
+
## 6.36.9
|
|
53
|
+
- **The queue gate can now see COS's own bindings, which closes a 30-minute lockout.**
|
|
54
|
+
`threadOccupancy` sees FOREIGN holders only — `OccupancyReason` has no
|
|
55
|
+
`native_target_busy`, because that refusal comes from the binding registry. So when a
|
|
56
|
+
live COS binding held a thread, Continue refused `native_target_busy`, the client
|
|
57
|
+
armed the queue, and this gate answered `409 thread_free` ("Pick Continue again"),
|
|
58
|
+
which refused identically. A closed loop for the whole binding TTL, while the refusal
|
|
59
|
+
copy said "detach that one first" — an action with no endpoint.
|
|
60
|
+
- The same gap burnt the delivery ceiling: the drainer saw `attachable`, ATTEMPTED, and
|
|
61
|
+
the attach route refused. Five of those retired a turn in about two minutes. With the
|
|
62
|
+
binding visible, `drainDecision` returns `hold` and spends nothing — one change closes
|
|
63
|
+
both the loop and the burn.
|
|
64
|
+
- **A gate refusal no longer spends an attempt.** Classified by REASON, never by status:
|
|
65
|
+
every refusal that is not `invalid_request` (400) or a capability gap (503) comes back
|
|
66
|
+
409, so a status rule would have retried `native_target_fenced` — "an earlier turn may
|
|
67
|
+
or may not have been delivered" — up to 1,080 times. Reuses `queueableRefusal`, and
|
|
68
|
+
fails CLOSED on an unrecognised reason.
|
|
69
|
+
- The attempt is still written and fsynced BEFORE the call, then REFUNDED once the
|
|
70
|
+
outcome is known. Deferring the increment would have reopened the crash-safety hole
|
|
71
|
+
the comment there describes, and a test reads `attempts` off disk mid-delivery to
|
|
72
|
+
prove it.
|
|
73
|
+
- **The refusal reason is no longer discarded.** `deliver` read `body.error`; the attach
|
|
74
|
+
route emits `reason` and has never emitted `error`, so every stored reason in the
|
|
75
|
+
field was the literal `attach_409` and the whole feature was reason-blind. Also
|
|
76
|
+
carries the turn route's own `retryable` verdict, which outranks our inference.
|
|
77
|
+
|
|
78
|
+
|
|
3
79
|
## 6.36.8
|
|
4
80
|
- **The queue now covers the refusal that actually fires.** Device diagnostics, once the
|
|
5
81
|
path was finally instrumented, recorded `native_target_busy` on every Continue that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.36.
|
|
3
|
+
"version": "6.36.10",
|
|
4
4
|
"description": "COS Glasses \u2014 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": {
|
package/server/index.ts
CHANGED
|
@@ -109,6 +109,7 @@ import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-tu
|
|
|
109
109
|
import { transcriptTurnEnded } from './lib/thread-turn-queue-store.js'
|
|
110
110
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
111
111
|
import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
|
|
112
|
+
import { readFences, writeFences } from './lib/thread-fence-store.js'
|
|
112
113
|
import type { QueuedThreadTurn } from './lib/thread-turn-queue.js'
|
|
113
114
|
|
|
114
115
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -517,10 +518,33 @@ app.use('/api', claudeSessionsRouter)
|
|
|
517
518
|
// buys the guarantee that the queue CANNOT weaken the gate even by accident.
|
|
518
519
|
if (threadAttachEnabled()) {
|
|
519
520
|
const queueDeps = {
|
|
521
|
+
// TWO GATES, ONE ANSWER. `threadOccupancy` sees FOREIGN holders -- another app on
|
|
522
|
+
// the Mac writing the transcript. It does not, and cannot, see COS's OWN bindings:
|
|
523
|
+
// `OccupancyReason` has no `native_target_busy` member, because that refusal is
|
|
524
|
+
// produced only by the binding registry inside `bindings.create`.
|
|
525
|
+
//
|
|
526
|
+
// That gap is what trapped Miles on 2026-08-18. Continue refused
|
|
527
|
+
// `native_target_busy` (a live COS binding held the thread), the client armed the
|
|
528
|
+
// queue, he dictated -- and this gate, asking occupancy alone, answered
|
|
529
|
+
// `attachable: true`, so the route replied `409 thread_free` ("Pick Continue again
|
|
530
|
+
// to send it"). Continue refused identically. A closed loop for the 30-minute
|
|
531
|
+
// binding TTL, with the refusal copy telling him to "detach that one first" -- an
|
|
532
|
+
// action with no endpoint.
|
|
533
|
+
//
|
|
534
|
+
// It also burnt the delivery ceiling: the drainer saw `attachable` and ATTEMPTED,
|
|
535
|
+
// the attach route refused, and five of those retired the turn in ~2 minutes. With
|
|
536
|
+
// the binding visible here, `drainDecision` returns 'hold' instead and no attempt
|
|
537
|
+
// is spent -- so this single change closes the loop AND the attempt burn.
|
|
520
538
|
occupancy: (provider: string, threadId: string) => {
|
|
521
539
|
try {
|
|
522
540
|
const v = threadOccupancy(provider, threadId, occupancyProbes, occupancyDirs)
|
|
523
|
-
return { attachable:
|
|
541
|
+
if (v.attachable !== true) return { attachable: false, reason: v.reason ?? null }
|
|
542
|
+
// Occupancy is happy; ask the registry the question it cannot answer.
|
|
543
|
+
// `getByThread` returns the binding ONLY when it actually blocks the target
|
|
544
|
+
// (`blocksTarget`), so an expired or terminal one correctly reads as free.
|
|
545
|
+
const holder = agentSessionBindingRegistry.getByThread(provider, threadId, Date.now())
|
|
546
|
+
if (holder !== null) return { attachable: false, reason: 'native_target_busy' }
|
|
547
|
+
return { attachable: true, reason: null }
|
|
524
548
|
} catch {
|
|
525
549
|
// A throwing probe is not an open door.
|
|
526
550
|
return { attachable: false, reason: 'probe_failed' }
|
|
@@ -559,6 +583,23 @@ if (threadAttachEnabled()) {
|
|
|
559
583
|
}
|
|
560
584
|
|
|
561
585
|
app.use('/api', createAgentSessionBindingsRouter({
|
|
586
|
+
// Durable fences (6.36.10), OFF BY DEFAULT.
|
|
587
|
+
//
|
|
588
|
+
// The fence is the one piece of state whose loss writes twice into a real
|
|
589
|
+
// conversation, so persisting it is the right direction. But durability
|
|
590
|
+
// without a reachable release is a REGRESSION, not a fix: today "Restart
|
|
591
|
+
// Server" in COS Control clears a fence, and making it survive restarts with
|
|
592
|
+
// no operator surface in Control turns an 8-second annoyance into a
|
|
593
|
+
// permanently dead thread that needs a terminal to clear. Miles, 2026-08-12:
|
|
594
|
+
// "we couldn't do anything without bash, that shouldn't be the case."
|
|
595
|
+
//
|
|
596
|
+
// So the storage ships inert. GET /agent-sessions/fences and
|
|
597
|
+
// POST /agent-sessions/fences/release work either way — which already removes
|
|
598
|
+
// the restart from the recovery path — and this flag flips on once COS Control
|
|
599
|
+
// has a Fences card.
|
|
600
|
+
fencePersistence: process.env.COS_THREAD_FENCE_DURABLE === '1'
|
|
601
|
+
? { load: readFences, save: writeFences }
|
|
602
|
+
: undefined,
|
|
562
603
|
probes: occupancyProbes,
|
|
563
604
|
dirs: occupancyDirs,
|
|
564
605
|
now: () => Date.now(),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Durable storage for target fences.
|
|
2
|
+
//
|
|
3
|
+
// A fence shuts a native thread that may already hold an undelivered COS turn. It
|
|
4
|
+
// is the one piece of state whose LOSS writes twice into a real human conversation
|
|
5
|
+
// — agent-session-binding-registry.ts records the incident verbatim: "the
|
|
6
|
+
// process-local fence re-opened on restart and delivered a second copy."
|
|
7
|
+
//
|
|
8
|
+
// SEPARATE FROM THE DECISIONS, matching thread-turn-queue-store.ts: TargetGuard
|
|
9
|
+
// holds the rules and takes load/save as injected callbacks, so its behaviour stays
|
|
10
|
+
// testable in memory and only production touches the disk.
|
|
11
|
+
//
|
|
12
|
+
// UNDER THE DATA HOME, never the generation directory, which Update Server replaces
|
|
13
|
+
// wholesale. Same lesson as the stranded voice profiles.
|
|
14
|
+
//
|
|
15
|
+
// NEVER TTL'd AND NEVER EVICTED. `heads` and fork refs both bound their maps, and
|
|
16
|
+
// both evict in the SAFE direction — losing a head asks the user to acknowledge,
|
|
17
|
+
// losing a fork ref makes them find the thread by hand. Losing a fence silently
|
|
18
|
+
// reopens a thread that may hold an undelivered turn, so the only way an entry
|
|
19
|
+
// leaves this file is an explicit operator release.
|
|
20
|
+
|
|
21
|
+
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
22
|
+
import { dataPath } from './data-dir.js'
|
|
23
|
+
|
|
24
|
+
export interface FenceRecord {
|
|
25
|
+
/** The raw target key. On disk only — it embeds the private native thread id and
|
|
26
|
+
* is never emitted by any route. Callers outside this module use the digest. */
|
|
27
|
+
targetKey: string
|
|
28
|
+
provider: string
|
|
29
|
+
reason: string
|
|
30
|
+
/** The head digest as it stood BEFORE the ambiguous turn. Null ONLY when the
|
|
31
|
+
* failure happened before the head was read — `head` is scoped to the try, so
|
|
32
|
+
* the route-error site reads a hoisted copy rather than nothing. */
|
|
33
|
+
headBefore: string | null
|
|
34
|
+
turnId: string
|
|
35
|
+
bindingId: string | null
|
|
36
|
+
fencedAt: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fencePath(): string {
|
|
40
|
+
return dataPath('thread-fences.json')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isFenceRecord(r: unknown): r is FenceRecord {
|
|
44
|
+
return !!r && typeof r === 'object'
|
|
45
|
+
&& typeof (r as FenceRecord).targetKey === 'string' && (r as FenceRecord).targetKey.length > 0
|
|
46
|
+
&& typeof (r as FenceRecord).provider === 'string'
|
|
47
|
+
&& typeof (r as FenceRecord).reason === 'string'
|
|
48
|
+
&& typeof (r as FenceRecord).fencedAt === 'number'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw array on disk, or [] when the file is missing or was quarantined. */
|
|
52
|
+
function rawRows(): unknown[] {
|
|
53
|
+
const loaded = loadJsonOrQuarantine<unknown>(fencePath())
|
|
54
|
+
if (loaded.status === 'corrupt') {
|
|
55
|
+
// Quarantined to `<path>.corrupt-<ts>` rather than discarded: the bytes are
|
|
56
|
+
// the only record of which threads were fenced, and this is the one state
|
|
57
|
+
// whose silent loss double-writes a real conversation.
|
|
58
|
+
console.warn(`[thread-fence-store] fence file was corrupt, quarantined as ${loaded.quarantinedAs}`)
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
if (loaded.status !== 'ok') return []
|
|
62
|
+
if (!Array.isArray(loaded.data)) {
|
|
63
|
+
console.warn('[thread-fence-store] fence file is not an array — treating as empty')
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
return loaded.data
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every stored fence this build can understand.
|
|
71
|
+
*
|
|
72
|
+
* A missing or corrupt file reads as empty. That fails OPEN, deliberately:
|
|
73
|
+
* failing closed would refuse every thread on the machine with no way back,
|
|
74
|
+
* while failing open is exactly the pre-6.36.10 behaviour (the fence was
|
|
75
|
+
* process-local and died on restart), so it cannot be a regression. A corrupt
|
|
76
|
+
* file is quarantined rather than dropped, so the evidence survives.
|
|
77
|
+
*/
|
|
78
|
+
export function readFences(): FenceRecord[] {
|
|
79
|
+
return rawRows().filter(isFenceRecord)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replace the stored set, PRESERVING rows this build could not validate.
|
|
84
|
+
*
|
|
85
|
+
* THE MERGE IS THE WHOLE POINT. `TargetGuard` holds only the rows `readFences`
|
|
86
|
+
* understood and saves its map wholesale, so without this a single unrecognised
|
|
87
|
+
* row — a newer schema, a partial write, one bad field — would be erased by the
|
|
88
|
+
* next fence on an unrelated thread, silently reopening every other fenced
|
|
89
|
+
* thread. Preserved rows are inert (nothing enforces a fence that is not in the
|
|
90
|
+
* map) but they are never destroyed by a write that did not understand them.
|
|
91
|
+
*
|
|
92
|
+
* Uses the DURABLE writer, not the lightweight one: fsync of bytes, metadata and
|
|
93
|
+
* directory, plus a randomized exclusive temp name so two independent writers
|
|
94
|
+
* cannot share `<path>.tmp`.
|
|
95
|
+
*/
|
|
96
|
+
export function writeFences(rows: FenceRecord[]): void {
|
|
97
|
+
const preserved = rawRows().filter(r => !isFenceRecord(r))
|
|
98
|
+
if (preserved.length > 0) {
|
|
99
|
+
console.warn(`[thread-fence-store] preserving ${preserved.length} unrecognised fence row(s) through this write`)
|
|
100
|
+
}
|
|
101
|
+
durableAtomicWriteFileSync(fencePath(), `${JSON.stringify([...rows, ...preserved], null, 2)}\n`)
|
|
102
|
+
}
|
|
@@ -75,7 +75,7 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
75
75
|
turn: QueuedThreadTurn,
|
|
76
76
|
port: number,
|
|
77
77
|
token: string,
|
|
78
|
-
): Promise<{ ok: boolean; reason?: string }> {
|
|
78
|
+
): Promise<{ ok: boolean; reason?: string; serverRetryable?: boolean }> {
|
|
79
79
|
try {
|
|
80
80
|
const attach = await post(
|
|
81
81
|
port, token,
|
|
@@ -83,8 +83,17 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
83
83
|
{ cosSessionId: turn.cosSessionId },
|
|
84
84
|
)
|
|
85
85
|
if (attach.status !== 200 && attach.status !== 201) {
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// THE KEY IS `reason`, NOT `error`. `refuseAttach` emits
|
|
87
|
+
// `{ attached: false, reason, reasonCopy }` and has never emitted an `error`
|
|
88
|
+
// key, so reading `body.error` always fell through to the status fallback and
|
|
89
|
+
// every stored reason in the field was the literal string `attach_409`.
|
|
90
|
+
//
|
|
91
|
+
// That made the whole feature reason-blind: 409 is the DEFAULT status for every
|
|
92
|
+
// refusal that is not `invalid_request` (400) or a capability gap (503), so
|
|
93
|
+
// `attach_409` covered transient `native_target_busy` and permanent
|
|
94
|
+
// `native_target_fenced` alike. Any retry policy keyed on the status would have
|
|
95
|
+
// treated "an earlier turn may or may not have been delivered" as retryable.
|
|
96
|
+
return { ok: false, reason: String(attach.body.reason ?? attach.body.error ?? `attach_${attach.status}`) }
|
|
88
97
|
}
|
|
89
98
|
const bindingId = typeof attach.body.bindingId === 'string' ? attach.body.bindingId : ''
|
|
90
99
|
if (!bindingId) return { ok: false, reason: 'attach_no_binding' }
|
|
@@ -99,7 +108,14 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
99
108
|
)
|
|
100
109
|
// 202 is the success shape: admitted, delivered in the background, poll the ledger.
|
|
101
110
|
if (sent.status === 202 || sent.status === 200) return { ok: true }
|
|
102
|
-
|
|
111
|
+
// Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
|
|
112
|
+
// and no `error`. `retryable` is the server's OWN judgement about this refusal --
|
|
113
|
+
// carried through rather than re-derived, so the two layers cannot disagree.
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: String(sent.body.reason ?? sent.body.error ?? `turn_${sent.status}`),
|
|
117
|
+
serverRetryable: typeof sent.body.retryable === 'boolean' ? sent.body.retryable : undefined,
|
|
118
|
+
}
|
|
103
119
|
} catch (error) {
|
|
104
120
|
return { ok: false, reason: error instanceof Error ? error.message : 'deliver_failed' }
|
|
105
121
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
// does not — a future remount above the parser — the POST routes see a non-object
|
|
95
95
|
// and answer 400. They never treat an unparsed body as an empty one.
|
|
96
96
|
|
|
97
|
+
import type { FenceRecord } from '../lib/thread-fence-store.js'
|
|
97
98
|
import { Router, type Request, type Response } from 'express'
|
|
98
99
|
import { createHash, randomUUID } from 'node:crypto'
|
|
99
100
|
import {
|
|
@@ -268,6 +269,12 @@ export type AttachedTurnResult =
|
|
|
268
269
|
| { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
|
|
269
270
|
|
|
270
271
|
export interface AgentSessionBindingsDeps {
|
|
272
|
+
/**
|
|
273
|
+
* Durable fence storage. OPTIONAL, and omitting it is what keeps the existing
|
|
274
|
+
* suite in memory: a test that silently began writing the real data home would
|
|
275
|
+
* leak fences between cases and into the running server. Production wires it.
|
|
276
|
+
*/
|
|
277
|
+
fencePersistence?: FencePersistence
|
|
271
278
|
probes: OccupancyProbes
|
|
272
279
|
dirs: OccupancyDirs
|
|
273
280
|
/** Epoch ms. Injected so lease expiry is decidable in a test without waiting. */
|
|
@@ -829,24 +836,86 @@ export const MAX_TRACKED_HEADS = 512
|
|
|
829
836
|
/** A COS session id may contain ':' and '/', which is exactly why it is never projected. */
|
|
830
837
|
export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
|
|
831
838
|
|
|
839
|
+
/** What a fence records about the turn that set it. */
|
|
840
|
+
export interface FenceEvidence {
|
|
841
|
+
provider: string
|
|
842
|
+
/** The head BEFORE the ambiguous turn. Null ONLY when the failure happened
|
|
843
|
+
* before the head was read. */
|
|
844
|
+
headBefore: string | null
|
|
845
|
+
turnId: string
|
|
846
|
+
bindingId: string | null
|
|
847
|
+
now: number
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export type ReleaseOutcome =
|
|
851
|
+
| { ok: true; row: FenceRecord }
|
|
852
|
+
| { ok: false; reason: 'unknown_fence' | 'persist_failed' }
|
|
853
|
+
|
|
854
|
+
/** Injected so TargetGuard stays testable in memory; production wires the store. */
|
|
855
|
+
export interface FencePersistence {
|
|
856
|
+
load: () => FenceRecord[]
|
|
857
|
+
save: (rows: FenceRecord[]) => void
|
|
858
|
+
}
|
|
859
|
+
|
|
832
860
|
/**
|
|
833
|
-
*
|
|
834
|
-
*
|
|
861
|
+
* One COS turn per native target, and a target that may already hold an
|
|
862
|
+
* undelivered turn stays shut.
|
|
835
863
|
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
* and
|
|
842
|
-
*
|
|
843
|
-
*
|
|
864
|
+
* CLAIMS ARE PROCESS-LOCAL; FENCES ARE DURABLE (6.36.10). The two states fail in
|
|
865
|
+
* opposite directions, which is why only one of them is persisted. Losing a claim
|
|
866
|
+
* on restart is safe — no turn is running after a restart anyway. Losing a FENCE
|
|
867
|
+
* reopens a thread that may already hold an undelivered turn, and the binding
|
|
868
|
+
* registry records what that cost: "the process-local fence re-opened on restart
|
|
869
|
+
* and delivered a second copy." So fences are written through to disk and
|
|
870
|
+
* rehydrated in the constructor, and the ONLY way one leaves the map is an
|
|
871
|
+
* explicit operator release (`releaseFence`).
|
|
872
|
+
*
|
|
873
|
+
* Persistence is INJECTED rather than imported. A test that silently began writing
|
|
874
|
+
* the real data home would leak fences between cases and into the running server,
|
|
875
|
+
* so the suite runs with `null` and stays in memory.
|
|
844
876
|
*/
|
|
845
877
|
class TargetGuard {
|
|
846
878
|
/** targetKey -> turnId of the single COS turn allowed to be in flight. */
|
|
847
879
|
private readonly claims = new Map<string, string>()
|
|
848
|
-
/** targetKey ->
|
|
849
|
-
|
|
880
|
+
/** targetKey -> the fence record. DURABLE as of 6.36.10: persistence is injected
|
|
881
|
+
* so tests stay in memory and only production touches the data home. */
|
|
882
|
+
private readonly fences = new Map<string, FenceRecord>()
|
|
883
|
+
private readonly persistence: FencePersistence | null
|
|
884
|
+
private persistDegraded = false
|
|
885
|
+
|
|
886
|
+
constructor(persistence: FencePersistence | null = null) {
|
|
887
|
+
this.persistence = persistence
|
|
888
|
+
if (persistence === null) return
|
|
889
|
+
// Rehydrate BEFORE the router serves. A fence that died on restart is exactly
|
|
890
|
+
// how a second copy of a turn reached a real transcript.
|
|
891
|
+
try {
|
|
892
|
+
for (const row of persistence.load()) this.fences.set(row.targetKey, row)
|
|
893
|
+
} catch (error) {
|
|
894
|
+
console.error(`[agent-session-bindings] fence rehydrate failed: ${error instanceof Error ? error.message : error}`)
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** True when the durable write succeeded (or there is nothing to persist to). */
|
|
899
|
+
private persistFences(rows: FenceRecord[]): boolean {
|
|
900
|
+
if (this.persistence === null) return true
|
|
901
|
+
try {
|
|
902
|
+
this.persistence.save(rows)
|
|
903
|
+
this.persistDegraded = false
|
|
904
|
+
return true
|
|
905
|
+
} catch (error) {
|
|
906
|
+
// The in-memory fence still holds for this process, so the thread stays shut
|
|
907
|
+
// NOW; what is lost is survival across a restart. Loud, and surfaced on
|
|
908
|
+
// GET /fences — a silent fallback is indistinguishable from working.
|
|
909
|
+
this.persistDegraded = true
|
|
910
|
+
console.error(`[agent-session-bindings] fence persist FAILED (fences hold in memory only): ${error instanceof Error ? error.message : error}`)
|
|
911
|
+
return false
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Whether the last durable write failed. Reported, never inferred. */
|
|
916
|
+
degraded(): boolean {
|
|
917
|
+
return this.persistDegraded
|
|
918
|
+
}
|
|
850
919
|
/** bindingId -> the head digest this binding is currently reconciled to. */
|
|
851
920
|
private readonly heads = new Map<string, string>()
|
|
852
921
|
|
|
@@ -869,12 +938,72 @@ class TargetGuard {
|
|
|
869
938
|
if (this.claims.get(targetKey) === turnId) this.claims.delete(targetKey)
|
|
870
939
|
}
|
|
871
940
|
|
|
872
|
-
|
|
873
|
-
|
|
941
|
+
/**
|
|
942
|
+
* Write-once: the FIRST reason wins, so a later ambiguity cannot overwrite the
|
|
943
|
+
* evidence chain of an unresolved one.
|
|
944
|
+
*
|
|
945
|
+
* DEFENSIVE, AND UNVERIFIED BY EXECUTION. Both fence sites sit inside the
|
|
946
|
+
* `tryClaim` section, which serialises them per target, and a fenced target is
|
|
947
|
+
* refused at the check before it can reach either site again — so no route can
|
|
948
|
+
* currently fence the same key twice, and a mutation removing this guard passes
|
|
949
|
+
* the whole suite. It is kept because the one path that could reach it (the
|
|
950
|
+
* ambiguous site fences, then the response throws into the catch, which fences
|
|
951
|
+
* `claimedKey` again) would otherwise replace a record carrying `bindingId` with
|
|
952
|
+
* one carrying null. Do not read the passing suite as coverage of this line.
|
|
953
|
+
*/
|
|
954
|
+
fence(targetKey: string, reason: WriteRefusal, evidence: FenceEvidence): void {
|
|
955
|
+
if (this.fences.has(targetKey)) return
|
|
956
|
+
this.fences.set(targetKey, {
|
|
957
|
+
targetKey,
|
|
958
|
+
provider: evidence.provider,
|
|
959
|
+
reason,
|
|
960
|
+
headBefore: evidence.headBefore,
|
|
961
|
+
turnId: evidence.turnId,
|
|
962
|
+
bindingId: evidence.bindingId,
|
|
963
|
+
fencedAt: evidence.now,
|
|
964
|
+
})
|
|
965
|
+
this.persistFences([...this.fences.values()])
|
|
874
966
|
}
|
|
875
967
|
|
|
876
968
|
fencedReason(targetKey: string): WriteRefusal | null {
|
|
877
|
-
|
|
969
|
+
const row = this.fences.get(targetKey)
|
|
970
|
+
return row === undefined ? null : (row.reason as WriteRefusal)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Every fence, REDACTED for the wire: the raw targetKey embeds the private
|
|
974
|
+
* native thread id, so callers address a fence by its deterministic digest. */
|
|
975
|
+
listFences(): Array<{ target: string; provider: string; reason: string; headBefore: string | null; turnId: string; fencedAt: number }> {
|
|
976
|
+
return [...this.fences.values()].map(row => ({
|
|
977
|
+
target: opaqueRevision(row.targetKey),
|
|
978
|
+
provider: row.provider,
|
|
979
|
+
reason: row.reason,
|
|
980
|
+
headBefore: row.headBefore,
|
|
981
|
+
turnId: row.turnId,
|
|
982
|
+
fencedAt: row.fencedAt,
|
|
983
|
+
}))
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The operator release. THE ONLY WAY A FENCE LEAVES THIS MAP.
|
|
988
|
+
*
|
|
989
|
+
* Addressed by digest, never by raw target key. Returns false when no fence
|
|
990
|
+
* matches, so a stale handle reports honestly instead of silently succeeding.
|
|
991
|
+
*/
|
|
992
|
+
releaseFence(targetDigest: string): ReleaseOutcome {
|
|
993
|
+
for (const [key, row] of this.fences) {
|
|
994
|
+
// THE authority on which fence a handle names. The route also looks the row
|
|
995
|
+
// up for its preview, but the release decision is made here — a duplicate
|
|
996
|
+
// lookup upstream would leave this comparison enforced by nothing.
|
|
997
|
+
if (opaqueRevision(row.targetKey) !== targetDigest) continue
|
|
998
|
+
// PERSIST FIRST. Reporting a release that was not durably recorded is how an
|
|
999
|
+
// operator is told a thread is open, writes to it, and finds it fenced again
|
|
1000
|
+
// after the next restart with no record of why.
|
|
1001
|
+
const remaining = [...this.fences.values()].filter(r => r.targetKey !== key)
|
|
1002
|
+
if (!this.persistFences(remaining)) return { ok: false, reason: 'persist_failed' }
|
|
1003
|
+
this.fences.delete(key)
|
|
1004
|
+
return { ok: true, row }
|
|
1005
|
+
}
|
|
1006
|
+
return { ok: false, reason: 'unknown_fence' }
|
|
878
1007
|
}
|
|
879
1008
|
|
|
880
1009
|
/**
|
|
@@ -1135,7 +1264,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1135
1264
|
const canListBindings = bindingDepsUsable(deps)
|
|
1136
1265
|
const canWriteBindings = bindingWriteDepsUsable(deps)
|
|
1137
1266
|
const detect = deps?.occupancy ?? threadOccupancy
|
|
1138
|
-
const guard = new TargetGuard()
|
|
1267
|
+
const guard = new TargetGuard(deps.fencePersistence ?? null)
|
|
1139
1268
|
const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
|
|
1140
1269
|
// One per router. Injectable so the follow-on (attach accepting a `forkRef`)
|
|
1141
1270
|
// shares this instance rather than standing up a second, disconnected one.
|
|
@@ -1216,6 +1345,54 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1216
1345
|
}
|
|
1217
1346
|
}
|
|
1218
1347
|
|
|
1348
|
+
// ------------------------------------------------------------------ fences
|
|
1349
|
+
//
|
|
1350
|
+
// Mounted BEFORE the parameterised routes so `fences` can never be read as a
|
|
1351
|
+
// provider. Both are operator surfaces: a fenced thread was previously
|
|
1352
|
+
// discoverable only by trying to use it and being refused.
|
|
1353
|
+
//
|
|
1354
|
+
// A fence is addressed by DIGEST. The raw target key embeds the private native
|
|
1355
|
+
// thread id, and the redaction contract at the top of this file is absolute.
|
|
1356
|
+
|
|
1357
|
+
router.get('/agent-sessions/fences', (_req, res) => {
|
|
1358
|
+
// A fence list is a liveness answer; a cached one is worse than none.
|
|
1359
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1360
|
+
// `degraded` is reported, never inferred: a memory-only fence set behaves
|
|
1361
|
+
// identically to a durable one until the process restarts, so a silent
|
|
1362
|
+
// fallback would be indistinguishable from working.
|
|
1363
|
+
res.json({ fences: guard.listFences(), degraded: guard.degraded() })
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
router.post('/agent-sessions/fences/release', (req, res) => {
|
|
1367
|
+
const body = (req.body ?? {}) as { target?: unknown; confirm?: unknown }
|
|
1368
|
+
const target = body.target
|
|
1369
|
+
if (typeof target !== 'string' || target.length === 0 || target.length > 256) {
|
|
1370
|
+
res.status(400).json({ released: false, reason: 'invalid_request' })
|
|
1371
|
+
return
|
|
1372
|
+
}
|
|
1373
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1374
|
+
if (body.confirm !== true) {
|
|
1375
|
+
// FAILS CLOSED, like every other destructive COS call. NOTE WHAT THIS IS
|
|
1376
|
+
// AND IS NOT: it is a deliberate second call, not proof a human looked.
|
|
1377
|
+
// The API token is shared by the phone, the lens and every COS agent
|
|
1378
|
+
// session, so nothing is structurally prevented from asserting `confirm`.
|
|
1379
|
+
// It stops an accidental release, not an automated one.
|
|
1380
|
+
const preview = guard.listFences().find(f => f.target === target) ?? null
|
|
1381
|
+
res.status(400).json({ released: false, reason: 'confirmation_required', preview })
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
// The guard decides. It re-matches the handle itself rather than trusting a
|
|
1385
|
+
// lookup performed up here, and it persists BEFORE it mutates.
|
|
1386
|
+
const outcome = guard.releaseFence(target)
|
|
1387
|
+
if (!outcome.ok) {
|
|
1388
|
+
const status = outcome.reason === 'unknown_fence' ? 404 : 500
|
|
1389
|
+
res.status(status).json({ released: false, reason: outcome.reason })
|
|
1390
|
+
return
|
|
1391
|
+
}
|
|
1392
|
+
console.warn(`[agent-session-bindings] fence RELEASED by operator target=${target} provider=${outcome.row.provider} fencedAt=${outcome.row.fencedAt}`)
|
|
1393
|
+
res.json({ released: true, target, provider: outcome.row.provider })
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1219
1396
|
router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
|
|
1220
1397
|
// An occupancy verdict is a liveness answer with a lifetime of roughly now.
|
|
1221
1398
|
// A cached `attachable: true` is indistinguishable from a stale one, which is
|
|
@@ -1375,7 +1552,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1375
1552
|
// again just because the binding that delivered it is gone. Checked here as
|
|
1376
1553
|
// well as in the turn route, because a fresh attach is the obvious way around
|
|
1377
1554
|
// a per-binding fence.
|
|
1378
|
-
if (fenced !== null)
|
|
1555
|
+
if (fenced !== null) {
|
|
1556
|
+
console.warn(`[agent-session-bindings] fence hit route=attach provider=${providerParam} target=${opaqueRevision(key)}`)
|
|
1557
|
+
return refuseAttach(res, fenced)
|
|
1558
|
+
}
|
|
1379
1559
|
|
|
1380
1560
|
const resolve = deps.resolveTarget
|
|
1381
1561
|
if (typeof resolve !== 'function') return refuseAttach(res, 'target_unresolvable')
|
|
@@ -1623,6 +1803,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1623
1803
|
const turnId = mintId()
|
|
1624
1804
|
/** The target we hold a claim on, released in the finally. */
|
|
1625
1805
|
let claimedKey: string | null = null
|
|
1806
|
+
// Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
|
|
1807
|
+
// both declared inside the try, so neither is in scope where the route-error
|
|
1808
|
+
// fence is set — without these it would store a fence it can say nothing about.
|
|
1809
|
+
let fenceProvider = ''
|
|
1810
|
+
let preTurnHeadDigest: string | null = null
|
|
1626
1811
|
/** Children the adapter reported, released in the finally. */
|
|
1627
1812
|
const recordedPids: number[] = []
|
|
1628
1813
|
let pinnedBindingId: string | null = null
|
|
@@ -1789,6 +1974,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1789
1974
|
if (gate?.ok !== true) return refuseTurn(registryRefusal(gate?.reason))
|
|
1790
1975
|
|
|
1791
1976
|
const binding = deps.bindings.get!(bindingId)
|
|
1977
|
+
if (binding) fenceProvider = binding.provider
|
|
1792
1978
|
// Only `active` runs work. `staging` is the pre-commit state of the journaled
|
|
1793
1979
|
// Chat handoff and must never execute against a Chat that can still roll back.
|
|
1794
1980
|
const usable = assertUsable(binding ?? null, now)
|
|
@@ -1805,6 +1991,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1805
1991
|
const key = binding.targetKey
|
|
1806
1992
|
const fenced = guard.fencedReason(key)
|
|
1807
1993
|
if (fenced !== null) {
|
|
1994
|
+
console.warn(`[agent-session-bindings] fence hit route=turn provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId}`)
|
|
1808
1995
|
return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
|
|
1809
1996
|
}
|
|
1810
1997
|
|
|
@@ -1823,6 +2010,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1823
2010
|
|
|
1824
2011
|
const head = await readHead(binding.provider, binding.nativeThreadId)
|
|
1825
2012
|
if (head === null) return refuseTurn('native_head_unavailable')
|
|
2013
|
+
preTurnHeadDigest = head.digest
|
|
1826
2014
|
|
|
1827
2015
|
// The attach baseline, advanced by each completed turn and by each explicit
|
|
1828
2016
|
// Continue Anyway. Without the advance the SECOND turn on a binding always
|
|
@@ -1928,7 +2116,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1928
2116
|
// Fenced under its own reason, not this turn's: `delivery_ambiguous`
|
|
1929
2117
|
// describes what happened to THIS request, while a later caller needs to
|
|
1930
2118
|
// be told the thread is shut and why it must be inspected first.
|
|
1931
|
-
guard.fence(key, 'native_target_fenced'
|
|
2119
|
+
guard.fence(key, 'native_target_fenced', {
|
|
2120
|
+
provider: binding.provider,
|
|
2121
|
+
headBefore: head.digest,
|
|
2122
|
+
turnId,
|
|
2123
|
+
bindingId,
|
|
2124
|
+
now: Date.now(),
|
|
2125
|
+
})
|
|
2126
|
+
// A fence shuts a thread until a human acts, and until now it wrote NO log
|
|
2127
|
+
// line at either site — so a fenced thread was discoverable only by trying
|
|
2128
|
+
// to use it (Miles, 2026-08-18). Never log `key`: it embeds the private
|
|
2129
|
+
// 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}`)
|
|
1932
2131
|
return reportAmbiguous()
|
|
1933
2132
|
}
|
|
1934
2133
|
|
|
@@ -1958,7 +2157,21 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1958
2157
|
if (deliveryAttempted) {
|
|
1959
2158
|
// A bug in this route that happened AROUND a delivery is indistinguishable
|
|
1960
2159
|
// from a delivery.
|
|
1961
|
-
if (claimedKey !== null)
|
|
2160
|
+
if (claimedKey !== null) {
|
|
2161
|
+
guard.fence(claimedKey, 'native_target_fenced', {
|
|
2162
|
+
provider: fenceProvider,
|
|
2163
|
+
headBefore: preTurnHeadDigest,
|
|
2164
|
+
turnId,
|
|
2165
|
+
bindingId: null,
|
|
2166
|
+
now: Date.now(),
|
|
2167
|
+
})
|
|
2168
|
+
// `head` is scoped to the try, so `preTurnHeadDigest` is hoisted to the
|
|
2169
|
+
// handler specifically to reach this site. It is null ONLY when the throw
|
|
2170
|
+
// happened before the head was read. An earlier version of this line
|
|
2171
|
+
// hardcoded `unavailable` and so contradicted the record it had just
|
|
2172
|
+
// 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'}`)
|
|
2174
|
+
}
|
|
1962
2175
|
reportAmbiguous()
|
|
1963
2176
|
} else {
|
|
1964
2177
|
refuseTurn('turn_failed')
|
|
@@ -36,7 +36,12 @@ export interface ThreadTurnQueueDeps {
|
|
|
36
36
|
* ambiguous must resolve `ok: false` with a reason: an unknown delivery that is
|
|
37
37
|
* retried puts the same sentence into a real conversation twice.
|
|
38
38
|
*/
|
|
39
|
-
deliver: (turn: QueuedThreadTurn) => Promise<{
|
|
39
|
+
deliver: (turn: QueuedThreadTurn) => Promise<{
|
|
40
|
+
ok: boolean
|
|
41
|
+
reason?: string
|
|
42
|
+
/** The turn route's OWN `retryable` judgement, when it sent one. Authoritative. */
|
|
43
|
+
serverRetryable?: boolean
|
|
44
|
+
}>
|
|
40
45
|
now: () => number
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -54,6 +59,31 @@ function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unk
|
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Is this failed delivery a "not yet" rather than a "no"?
|
|
64
|
+
*
|
|
65
|
+
* CLASSIFIED BY REASON, NEVER BY STATUS. Every attach refusal that is not
|
|
66
|
+
* `invalid_request` (400) or a capability gap (503) comes back 409 -- transient
|
|
67
|
+
* `native_target_busy` and permanent `native_target_fenced` alike -- so a status-based
|
|
68
|
+
* rule would retry a turn whose copy reads "an earlier turn on this thread may or may
|
|
69
|
+
* not have been delivered" up to 1,080 times inside the TTL. That is the one refusal
|
|
70
|
+
* that needs a human.
|
|
71
|
+
*
|
|
72
|
+
* `queueableRefusal` is reused rather than a second list being written: it already
|
|
73
|
+
* encodes "can this condition ever pass", it is tested, and a turn should be retried
|
|
74
|
+
* for exactly the reasons it was allowed to queue for.
|
|
75
|
+
*
|
|
76
|
+
* FAILS CLOSED. An unrecognised reason, an absent reason, or a thrown deliver all
|
|
77
|
+
* return false and spend an attempt. A new refusal added upstream is therefore bounded
|
|
78
|
+
* by default rather than silently retried forever.
|
|
79
|
+
*/
|
|
80
|
+
function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boolean }): boolean {
|
|
81
|
+
// The turn route publishes its own verdict; it outranks our inference either way.
|
|
82
|
+
if (outcome.serverRetryable === false) return false
|
|
83
|
+
if (outcome.serverRetryable === true) return true
|
|
84
|
+
return queueableRefusal(outcome.reason)
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
/**
|
|
58
88
|
* One drain pass over one thread.
|
|
59
89
|
*
|
|
@@ -103,7 +133,7 @@ export async function drainThread(
|
|
|
103
133
|
writeQueue(provider, threadId, queue)
|
|
104
134
|
dirty = true
|
|
105
135
|
|
|
106
|
-
let outcome: { ok: boolean; reason?: string }
|
|
136
|
+
let outcome: { ok: boolean; reason?: string; serverRetryable?: boolean }
|
|
107
137
|
try {
|
|
108
138
|
outcome = await deps.deliver(turn)
|
|
109
139
|
} catch (error) {
|
|
@@ -114,6 +144,20 @@ export async function drainThread(
|
|
|
114
144
|
turn.status = 'delivered'
|
|
115
145
|
turn.settledAt = deps.now()
|
|
116
146
|
delivered += 1
|
|
147
|
+
} else if (isRetryableDelivery(outcome)) {
|
|
148
|
+
// A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
|
|
149
|
+
// measured cost of getting this wrong: three of Miles's turns retired in about
|
|
150
|
+
// two minutes each, never delivered, while the 6h TTL never got to matter.
|
|
151
|
+
//
|
|
152
|
+
// REFUNDED, not deferred. The increment and its fsync stay BEFORE the call
|
|
153
|
+
// (routes:101-103) because that is what survives a crash mid-delivery -- the
|
|
154
|
+
// ceiling only bounds anything if it outlives the crash it is bounding, and a
|
|
155
|
+
// test reads `attempts` off disk from inside the deliver callback to prove it.
|
|
156
|
+
// So the attempt is spent first and given back here, where the outcome is known.
|
|
157
|
+
turn.attempts = Math.max(0, turn.attempts - 1)
|
|
158
|
+
turn.status = 'waiting'
|
|
159
|
+
turn.reason = outcome.reason
|
|
160
|
+
held += 1
|
|
117
161
|
} else if (turn.attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
118
162
|
turn.status = 'refused'
|
|
119
163
|
turn.reason = outcome.reason ?? 'delivery_failed'
|