@gotcos/glasses-server 6.36.9 → 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 +49 -0
- package/package.json +1 -1
- package/server/index.ts +18 -0
- package/server/lib/thread-fence-store.ts +102 -0
- package/server/routes/agent-session-bindings.ts +232 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
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
|
+
|
|
3
52
|
## 6.36.9
|
|
4
53
|
- **The queue gate can now see COS's own bindings, which closes a 30-minute lockout.**
|
|
5
54
|
`threadOccupancy` sees FOREIGN holders only — `OccupancyReason` has no
|
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)
|
|
@@ -582,6 +583,23 @@ if (threadAttachEnabled()) {
|
|
|
582
583
|
}
|
|
583
584
|
|
|
584
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,
|
|
585
603
|
probes: occupancyProbes,
|
|
586
604
|
dirs: occupancyDirs,
|
|
587
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
|
+
}
|
|
@@ -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')
|