@gotcos/glasses-server 6.27.13 → 6.28.0
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 +29 -0
- package/package.json +1 -1
- package/server/index.ts +141 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +1550 -0
|
@@ -0,0 +1,1550 @@
|
|
|
1
|
+
// GET /api/agent-sessions/:provider/:threadId/attachability
|
|
2
|
+
// GET /api/agent-sessions/bindings
|
|
3
|
+
// POST /api/agent-sessions/:provider/:threadId/attach
|
|
4
|
+
// POST /api/agent-sessions/bindings/:bindingId/turns
|
|
5
|
+
//
|
|
6
|
+
// The client-facing half of Continue Original Agent Thread. Phase 0 resolved
|
|
7
|
+
// plan 4.3 to option B: COS attaches to a native desktop thread ONLY when no
|
|
8
|
+
// live process owns it. This router is where the lens and Control ask that
|
|
9
|
+
// question, and it is the ONLY thing standing between a "not sure" and a write
|
|
10
|
+
// into a conversation somebody else is holding.
|
|
11
|
+
//
|
|
12
|
+
// FAIL CLOSED, WITHOUT EXCEPTION. Every unknown, throw, missing dependency,
|
|
13
|
+
// unreadable row and unrecognised input resolves to not-attachable with a named
|
|
14
|
+
// reason. The sibling detector's header lists six inputs where the first version
|
|
15
|
+
// turned "I found no owner" into "there is no owner"; this file must not add a
|
|
16
|
+
// seventh at the edge. There is exactly one way to reach `attachable: true`:
|
|
17
|
+
// a supported provider, a valid id, a detector that demonstrably exists on this
|
|
18
|
+
// install, every candidate record parsed, and no owner that is not provably ours.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THIS ROUTE MAY NOT SAY. Plan 3.3 requires the client-visible reference to
|
|
21
|
+
// be non-identifying, and lib/claude-session-registry.ts:117 goes to real trouble
|
|
22
|
+
// never to spread a raw registry record toward a lens. So the response body is
|
|
23
|
+
// four fields, listed by hand, and carries NO pid, NO native thread id, NO
|
|
24
|
+
// filesystem or socket path, NO cwd and NO target lock key. `ownerCount` is the
|
|
25
|
+
// entire owner projection. The test asserts the exact key set and that the whole
|
|
26
|
+
// serialized body contains no '/' at all, which is a cheap standing proof that
|
|
27
|
+
// nothing path-shaped ever leaks through a future edit.
|
|
28
|
+
//
|
|
29
|
+
// EVERYTHING IS INJECTED. Probes, directories, the clock and the binding registry
|
|
30
|
+
// all arrive as dependencies, following `createQueryJobsRouter` (routes/query-jobs.ts,
|
|
31
|
+
// registered at index.ts:269). That is what lets the whole verdict surface be
|
|
32
|
+
// tested without a real machine, a real Claude install, or a real wall clock.
|
|
33
|
+
//
|
|
34
|
+
// AUTH AND NETWORK POLICY ARE INHERITED, NOT INVENTED. index.ts applies the IP
|
|
35
|
+
// allowlist (:134), CORS (:144) and `requireApiToken` (:157) to everything under
|
|
36
|
+
// /api before any router is reached, exactly as `agentSessionsRouter` (:279) and
|
|
37
|
+
// `claudeSessionsRouter` (:282) rely on. This router adds no auth of its own and
|
|
38
|
+
// must be mounted the same way: `app.use('/api', createAgentSessionBindingsRouter(deps))`.
|
|
39
|
+
//
|
|
40
|
+
// NO FEATURE FLAG, deliberately. `claude-sessions` is dark by default because it
|
|
41
|
+
// projects another product's 0700 state directory onto the wire. This route
|
|
42
|
+
// projects a boolean, a reason enum and a count. There is nothing here to gate,
|
|
43
|
+
// and a flag would only add a state in which the lens cannot tell "unsafe to
|
|
44
|
+
// attach" from "switched off".
|
|
45
|
+
//
|
|
46
|
+
// ---------------------------------------------------------------- write side
|
|
47
|
+
//
|
|
48
|
+
// The two POST routes below take the same posture and add four rules of their
|
|
49
|
+
// own. They are the first code in this feature that can change a real desktop
|
|
50
|
+
// conversation, so read them as "what has to be TRUE before a prompt moves",
|
|
51
|
+
// never as "what has to be false before we refuse".
|
|
52
|
+
//
|
|
53
|
+
// 1. THE OCCUPANCY VERDICT IS A HARD PRECONDITION, TWICE. Plan 4.3 resolved to
|
|
54
|
+
// option B: protocol 1 attaches only to a thread with no live owner. Attach
|
|
55
|
+
// refuses on any doubt the detector can raise, and the turn route RE-RUNS the
|
|
56
|
+
// same detector immediately before delivery (plan 4.3 step 6). A newly
|
|
57
|
+
// appeared owner there is terminal, not a warning — there is no COS-side
|
|
58
|
+
// cross-process lock that could fence a desktop writer, so the only safe
|
|
59
|
+
// response to one appearing is to not deliver.
|
|
60
|
+
//
|
|
61
|
+
// 2. SELF-RECURSION ORDERING (plan 4.4). A `claude --resume <id>` child writes
|
|
62
|
+
// ITSELF into ~/.claude/sessions/<pid>.json carrying the id we are targeting,
|
|
63
|
+
// so from the next check's point of view our own child is a live foreign
|
|
64
|
+
// owner. The spawn ledger is the only thing that tells them apart and it is
|
|
65
|
+
// keyed pid -> process START, so the order is fixed:
|
|
66
|
+
// re-check -> spawn -> record(pid, MEASURED start) -> prompt -> release
|
|
67
|
+
// The start MUST come from `probes.processStartMs(pid)`. `Date.now()` drifts
|
|
68
|
+
// by up to 992 ms against a 1500 ms tolerance and silently disables
|
|
69
|
+
// self-identification under load, after which the second turn on a thread
|
|
70
|
+
// refuses forever and reads as a detector bug. That is why `onSpawn` returns
|
|
71
|
+
// a boolean: if COS cannot record the child it cannot recognise it, so the
|
|
72
|
+
// adapter must abort BEFORE any prompt byte rather than deliver a turn COS
|
|
73
|
+
// will mistake for someone else's next time.
|
|
74
|
+
//
|
|
75
|
+
// 3. UNKNOWN DELIVERY IS NOT FAILED DELIVERY. An adapter result this build does
|
|
76
|
+
// not recognise, or a throw, means the prompt MAY have landed in the real
|
|
77
|
+
// thread. It is reported as `deliveryState: 'unknown'` with `retryable:
|
|
78
|
+
// false`, and it FENCES the target so no later turn or re-attach can deliver
|
|
79
|
+
// a second copy (plan 4.6). Only an explicit `{ status: 'aborted' }` — which
|
|
80
|
+
// plan 4.6 item 4 restricts to "the provider demonstrably never opened the
|
|
81
|
+
// session" — is allowed to mean nothing happened.
|
|
82
|
+
//
|
|
83
|
+
// 4. THE TWO INJECTED MODULES ARE OPTIONAL AND THEIR ABSENCE IS A REFUSAL.
|
|
84
|
+
// `native-head.ts` and `attached-provider-adapter.ts` are being written in
|
|
85
|
+
// parallel, so this router declares the shape it needs and takes it through
|
|
86
|
+
// `deps`. Every new dependency is OPTIONAL in the type — index.ts constructs
|
|
87
|
+
// this router today and must keep compiling — and every route checks for it
|
|
88
|
+
// at request time and answers 503 with a named reason when it is missing.
|
|
89
|
+
// Optional in the type is not optional in behavior.
|
|
90
|
+
//
|
|
91
|
+
// BODY PARSING IS INHERITED, LIKE AUTH. index.ts installs
|
|
92
|
+
// `express.json({ limit: '10mb' })` at :262, before this router at :296, and no
|
|
93
|
+
// router in this repo mounts its own parser. So `req.body` arrives parsed. If it
|
|
94
|
+
// does not — a future remount above the parser — the POST routes see a non-object
|
|
95
|
+
// and answer 400. They never treat an unparsed body as an empty one.
|
|
96
|
+
|
|
97
|
+
import { Router, type Request, type Response } from 'express'
|
|
98
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
99
|
+
import {
|
|
100
|
+
threadOccupancy,
|
|
101
|
+
type Occupancy,
|
|
102
|
+
type OccupancyDirs,
|
|
103
|
+
type OccupancyProbes,
|
|
104
|
+
type OccupancyReason,
|
|
105
|
+
} from '../lib/thread-occupancy.js'
|
|
106
|
+
import {
|
|
107
|
+
BINDING_ID_RE,
|
|
108
|
+
assertUsable,
|
|
109
|
+
boundToMarker,
|
|
110
|
+
isBindableProvider,
|
|
111
|
+
isExpired,
|
|
112
|
+
isPinned,
|
|
113
|
+
isTerminal,
|
|
114
|
+
targetKey,
|
|
115
|
+
type BindableProvider,
|
|
116
|
+
type BindingState,
|
|
117
|
+
type NativeBinding,
|
|
118
|
+
} from '../lib/agent-session-binding-store.js'
|
|
119
|
+
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
120
|
+
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
121
|
+
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read side of the binding lease store.
|
|
125
|
+
*
|
|
126
|
+
* `list` MUST THROW on a read failure. Returning `[]` would make "I could not
|
|
127
|
+
* read the registry" byte-identical to "nothing is bound", which is the exact
|
|
128
|
+
* absence-inference this feature keeps re-learning. The route turns a throw into
|
|
129
|
+
* an explicit unavailable response so an empty list always means proved-empty.
|
|
130
|
+
*/
|
|
131
|
+
export interface BindingRegistry {
|
|
132
|
+
/** Every binding the server knows, terminal ones included. Filtering is this route's job. */
|
|
133
|
+
list: () => readonly NativeBinding[]
|
|
134
|
+
/**
|
|
135
|
+
* Idempotency. Optional so a caller can wire a registry without them, but a
|
|
136
|
+
* turns route built on such a registry cannot make a repeated POST safe - the
|
|
137
|
+
* route requires a client key and simply records nothing if these are absent.
|
|
138
|
+
*/
|
|
139
|
+
findTurn?: (bindingId: string, turnId: string) => { result: unknown } | null
|
|
140
|
+
recordTurn?: (bindingId: string, turnId: string, result: unknown, now: number) => unknown
|
|
141
|
+
/**
|
|
142
|
+
* Is the durable store behind `list` usable at all?
|
|
143
|
+
*
|
|
144
|
+
* REQUIRED, not optional, and this is the reason: `AgentSessionBindingRegistry`
|
|
145
|
+
* (lib/agent-session-binding-registry.ts:578) returns `[]` from `list()` when it
|
|
146
|
+
* hydrated `degraded`. Forwarding that as a 200 with an empty array would tell an
|
|
147
|
+
* operator "nothing is bound" when the truth is "the store could not be read" —
|
|
148
|
+
* the same absence-inference this whole feature keeps re-learning, relocated to
|
|
149
|
+
* the wiring seam where no amount of care inside the route can catch it. Making
|
|
150
|
+
* it a required member means a router cannot be constructed without an answer.
|
|
151
|
+
* Wire it as `() => registry.hydration.status !== 'degraded'`.
|
|
152
|
+
*/
|
|
153
|
+
available: () => boolean
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------- write side
|
|
156
|
+
//
|
|
157
|
+
// OPTIONAL IN THE TYPE, REQUIRED IN BEHAVIOR. `AgentSessionBindingRegistry`
|
|
158
|
+
// implements every one of these, so the production wiring at index.ts:296
|
|
159
|
+
// satisfies them by passing the registry itself and needs no edit. They are
|
|
160
|
+
// optional so that a read-only registry — which is what the GET routes were
|
|
161
|
+
// built against — still constructs, and so a build that forgets one gets a
|
|
162
|
+
// named 503 from the write routes instead of a TypeError at request time.
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Attach.
|
|
166
|
+
*
|
|
167
|
+
* `AgentSessionBindingRegistry.create` is the ONLY sanctioned way to mint a
|
|
168
|
+
* binding, and the reason is the epoch. It reads `priorEpoch` from the durable
|
|
169
|
+
* per-target high-water ledger itself and returns `caller_supplied_epoch` if a
|
|
170
|
+
* caller tries to pass one. Reading the epoch from a CURRENT in-memory binding
|
|
171
|
+
* — the obvious-looking alternative — reopens the replay window the epoch
|
|
172
|
+
* exists to close: after detach and eviction the next attach would restart at
|
|
173
|
+
* 1, and a prompt queued against the first attach would match it.
|
|
174
|
+
*/
|
|
175
|
+
create?: (input: {
|
|
176
|
+
bindingId: string
|
|
177
|
+
cosSessionId: string
|
|
178
|
+
provider: string
|
|
179
|
+
nativeThreadId: string
|
|
180
|
+
workspaceFingerprint: string
|
|
181
|
+
sourceFingerprint: string
|
|
182
|
+
nativeHeadAtAttach?: string | null
|
|
183
|
+
ttlMs: number
|
|
184
|
+
now: number
|
|
185
|
+
}) => RegistryResult
|
|
186
|
+
activate?: (bindingId: string, now: number) => RegistryResult
|
|
187
|
+
/** Frees a target whose attach failed halfway. Never used on a live binding. */
|
|
188
|
+
forceDetach?: (bindingId: string, now: number) => RegistryResult
|
|
189
|
+
get?: (bindingId: string) => NativeBinding | null
|
|
190
|
+
/** The durable epoch/state gate for a client-queued prompt. */
|
|
191
|
+
checkQueuedPrompt?: (
|
|
192
|
+
claim: { bindingId: string; epoch: number; targetKey: string },
|
|
193
|
+
now: number,
|
|
194
|
+
) => RegistryCheck
|
|
195
|
+
/** A rejection here is FATAL to the turn, per the registry's own caller contract. */
|
|
196
|
+
pin?: (bindingId: string, jobId: string, now: number) => RegistryResult
|
|
197
|
+
unpin?: (bindingId: string, jobId: string, now: number) => RegistryResult
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What attach resolves server-side because plan 4.2 forbids the client sending it. */
|
|
201
|
+
export interface TargetResolution {
|
|
202
|
+
workspaceFingerprint: string
|
|
203
|
+
sourceFingerprint: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The request handed to the attached provider adapter.
|
|
208
|
+
*
|
|
209
|
+
* Declared here rather than imported because `server/lib/attached-provider-adapter.ts`
|
|
210
|
+
* is being written in parallel. This is the contract the route requires; the
|
|
211
|
+
* wiring may shim a differently-shaped adapter onto it.
|
|
212
|
+
*/
|
|
213
|
+
export interface AttachedTurnRequest {
|
|
214
|
+
turnId: string
|
|
215
|
+
bindingId: string
|
|
216
|
+
epoch: number
|
|
217
|
+
provider: BindableProvider
|
|
218
|
+
/** Exact private native id. The adapter resumes THIS and nothing prefix-matched. */
|
|
219
|
+
nativeThreadId: string
|
|
220
|
+
workspaceFingerprint: string
|
|
221
|
+
sourceFingerprint: string
|
|
222
|
+
/**
|
|
223
|
+
* The RAW provider revision token read immediately before this call, for an
|
|
224
|
+
* adapter that can re-assert it at the provider. It never reaches the wire:
|
|
225
|
+
* everything this router returns is a digest of it.
|
|
226
|
+
*/
|
|
227
|
+
expectedNativeHead: string
|
|
228
|
+
/** Never logged, never persisted by this router, never echoed in a response. */
|
|
229
|
+
prompt: string
|
|
230
|
+
/**
|
|
231
|
+
* Called with the child pid the instant the process exists and BEFORE any
|
|
232
|
+
* prompt byte is written or any `turn/start` is sent.
|
|
233
|
+
*
|
|
234
|
+
* Returns true when COS recorded the spawn and the adapter may proceed. Returns
|
|
235
|
+
* FALSE when COS could not establish the child's process start and therefore
|
|
236
|
+
* cannot recognise it as its own later; the adapter MUST then kill the child
|
|
237
|
+
* and return `{ status: 'aborted' }` without delivering. Delivering anyway
|
|
238
|
+
* produces a turn that COS will read as a live foreign owner on the next check.
|
|
239
|
+
*
|
|
240
|
+
* An adapter that does not spawn a process (a socket-based `thread/resume`)
|
|
241
|
+
* simply never calls it.
|
|
242
|
+
*/
|
|
243
|
+
onSpawn: (pid: number) => boolean
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* What the adapter may claim.
|
|
248
|
+
*
|
|
249
|
+
* `aborted` is the ONLY value that means "nothing was delivered", and plan 4.6
|
|
250
|
+
* item 4 restricts it to the cases where the provider demonstrably never opened
|
|
251
|
+
* the session: spawn ENOENT, a non-zero exit before any transport handshake, an
|
|
252
|
+
* app-server rejecting `thread/resume`. Everything else — including a throw, a
|
|
253
|
+
* missing status and a status this build does not know — is AMBIGUOUS, because
|
|
254
|
+
* "I did not see the delivery" is not "the delivery did not happen".
|
|
255
|
+
*/
|
|
256
|
+
export type AttachedTurnResult =
|
|
257
|
+
| { status: 'completed'; nativeRevisionAfter?: string | null }
|
|
258
|
+
| { status: 'aborted'; reason?: string }
|
|
259
|
+
/**
|
|
260
|
+
* The shape `server/lib/attached-provider-adapter.ts` actually returns.
|
|
261
|
+
*
|
|
262
|
+
* Recognised STRUCTURALLY rather than by importing that module, so this router
|
|
263
|
+
* stays standalone and a change over there cannot break this build — it can
|
|
264
|
+
* only stop matching, which lands in the ambiguous default. The mapping mirrors
|
|
265
|
+
* that module's own `attachedDeliveryAmbiguous`: only `not_attempted` and
|
|
266
|
+
* `aborted` are proof that nothing was sent.
|
|
267
|
+
*/
|
|
268
|
+
| { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
|
|
269
|
+
|
|
270
|
+
export interface AgentSessionBindingsDeps {
|
|
271
|
+
probes: OccupancyProbes
|
|
272
|
+
dirs: OccupancyDirs
|
|
273
|
+
/** Epoch ms. Injected so lease expiry is decidable in a test without waiting. */
|
|
274
|
+
now: () => number
|
|
275
|
+
bindings: BindingRegistry
|
|
276
|
+
/**
|
|
277
|
+
* The occupancy detector. Defaults to the real one and should stay that way in
|
|
278
|
+
* production: the seam exists so a test can hand back a SELF-CONTRADICTORY
|
|
279
|
+
* verdict and prove this route still refuses it. Without the seam that guard
|
|
280
|
+
* would be unreachable, and an unreachable guard is an untested guard.
|
|
281
|
+
*/
|
|
282
|
+
occupancy?: (
|
|
283
|
+
provider: string,
|
|
284
|
+
threadId: string,
|
|
285
|
+
probes: OccupancyProbes,
|
|
286
|
+
dirs: OccupancyDirs,
|
|
287
|
+
) => Occupancy
|
|
288
|
+
|
|
289
|
+
// -------------------------------------------------------------- write side
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The execution fields plan 4.2 forbids the client from sending.
|
|
293
|
+
*
|
|
294
|
+
* Synchronous on purpose. Attach has no target claim held while it runs, so an
|
|
295
|
+
* await here would widen the window between the occupancy verdict and
|
|
296
|
+
* `create()` for nothing; the registry is the arbiter of a concurrent attach
|
|
297
|
+
* either way. Null means "could not resolve", which is a refusal.
|
|
298
|
+
*/
|
|
299
|
+
resolveTarget?: (provider: BindableProvider, nativeThreadId: string) => TargetResolution | null
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Bounded opaque revision token for the native thread, from
|
|
303
|
+
* `server/lib/native-head.ts`.
|
|
304
|
+
*
|
|
305
|
+
* Wire it as `(p, id) => nativeHead(p, id, realNativeHeadDeps())` so this
|
|
306
|
+
* router never has to know that module's dependency shape. Null means "could
|
|
307
|
+
* not determine", which is a refusal at BOTH attach and turn: with no baseline
|
|
308
|
+
* there is no divergence check, and plan 4.3 is the only thing making a desktop
|
|
309
|
+
* edit visible.
|
|
310
|
+
*
|
|
311
|
+
* The token never reaches the wire. Everything this router returns is
|
|
312
|
+
* `opaqueRevision()` of it, so a token that turns out to be path-shaped or
|
|
313
|
+
* content-bearing cannot leak through this surface.
|
|
314
|
+
*/
|
|
315
|
+
nativeHead?: (provider: BindableProvider, threadId: string) => Promise<string | null> | string | null
|
|
316
|
+
|
|
317
|
+
/** `deliverAttachedTurn` from `server/lib/attached-provider-adapter.ts`. */
|
|
318
|
+
deliverAttachedTurn?: (request: AttachedTurnRequest) => Promise<unknown> | unknown
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The self-recursion ledger. Defaults to the real process-wide one.
|
|
322
|
+
*
|
|
323
|
+
* `record` takes a MEASURED process start, never a wall clock. See rule 2 in
|
|
324
|
+
* the header.
|
|
325
|
+
*/
|
|
326
|
+
/** Overrides the env gate. Tests only; production reads threadAttachEnabled(). */
|
|
327
|
+
attachEnabled?: boolean
|
|
328
|
+
|
|
329
|
+
ownership?: {
|
|
330
|
+
record: (pid: number, startMs: number) => string
|
|
331
|
+
release: (pid: number) => boolean
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Lease TTL for a new binding. */
|
|
335
|
+
attachTtlMs?: number
|
|
336
|
+
/** Upper bound on one prompt. A prompt is not a payload. */
|
|
337
|
+
maxPromptChars?: number
|
|
338
|
+
/** Injected so a test can pin the minted ids. Must return an id matching BINDING_ID_RE. */
|
|
339
|
+
newId?: () => string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Human-facing footer copy, one line per reason.
|
|
344
|
+
*
|
|
345
|
+
* `Record<OccupancyReason, string>` is the enforcement: adding a member to the
|
|
346
|
+
* union without adding copy here is a compile error, so a new reason cannot ship
|
|
347
|
+
* as a blank footer. The test then drives every one of these through the real
|
|
348
|
+
* route, so a reason that exists in the map but is unreachable also fails.
|
|
349
|
+
*
|
|
350
|
+
* Plan 4.3 fixes the wording of the case that matters: a thread open on the
|
|
351
|
+
* desktop must read as a deliberate safety behavior, not a malfunction, and must
|
|
352
|
+
* offer Fork. No slashes in any string here, so the redaction assertion can be a
|
|
353
|
+
* flat "the body contains no path separator".
|
|
354
|
+
*/
|
|
355
|
+
export const REASON_COPY: Record<OccupancyReason, string> = {
|
|
356
|
+
// Not a fault and not a busy thread - the write feature is off. The copy says so
|
|
357
|
+
// plainly rather than implying something is wrong, because Fork-only is a
|
|
358
|
+
// supported permanent configuration, not a degraded one (plan 4.9).
|
|
359
|
+
attach_disabled:
|
|
360
|
+
'Continuing a thread on your Mac is turned off. COS is read-only here. Fork it instead.',
|
|
361
|
+
live_desktop_process:
|
|
362
|
+
'Open on your Mac. COS will not write into a thread another app is holding. Fork it instead.',
|
|
363
|
+
unsupported_provider:
|
|
364
|
+
'This assistant cannot be continued from COS yet. Fork it instead.',
|
|
365
|
+
invalid_thread_id:
|
|
366
|
+
'That thread reference is not a valid id. Fork it instead.',
|
|
367
|
+
detector_unavailable:
|
|
368
|
+
'COS cannot check for a live owner on this Mac, so it will not continue this thread. Fork it instead.',
|
|
369
|
+
registry_unreadable:
|
|
370
|
+
'COS could not read the session records for this thread, so it will not continue it. Fork it instead.',
|
|
371
|
+
unverifiable_process_start:
|
|
372
|
+
'COS could not confirm which process holds this thread, so it will not continue it. Fork it instead.',
|
|
373
|
+
unverifiable_liveness_socket:
|
|
374
|
+
'COS could not confirm the owning app is still live, so it will not continue this thread. Fork it instead.',
|
|
375
|
+
probe_failed:
|
|
376
|
+
'The live owner check failed, so COS will not continue this thread. Fork it instead.',
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export const ATTACHABLE_COPY = 'Ready to continue in the original thread.'
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Copy for a reason string this build does not recognise.
|
|
383
|
+
*
|
|
384
|
+
* Reachable only if the detector returns a value outside its own union, i.e. a
|
|
385
|
+
* bug. The safe rendering of a bug is a refusal with words, never an empty
|
|
386
|
+
* footer and never a silent attach.
|
|
387
|
+
*/
|
|
388
|
+
export const UNKNOWN_REASON_COPY = 'COS could not establish whether this thread is free. Fork it instead.'
|
|
389
|
+
|
|
390
|
+
export function reasonCopy(reason: OccupancyReason | null): string {
|
|
391
|
+
if (reason === null) return ATTACHABLE_COPY
|
|
392
|
+
const copy = Object.prototype.hasOwnProperty.call(REASON_COPY, reason) ? REASON_COPY[reason] : undefined
|
|
393
|
+
return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Every way a write can be refused.
|
|
398
|
+
*
|
|
399
|
+
* Supersets `OccupancyReason` so the attach refusal for a live desktop owner is
|
|
400
|
+
* literally the same value and the same footer line the attachability probe
|
|
401
|
+
* already returns — a lens that renders one renders the other.
|
|
402
|
+
*/
|
|
403
|
+
export type WriteRefusal =
|
|
404
|
+
| OccupancyReason
|
|
405
|
+
| 'invalid_request'
|
|
406
|
+
| 'binding_registry_unwired'
|
|
407
|
+
| 'binding_registry_degraded'
|
|
408
|
+
| 'binding_registry_unavailable'
|
|
409
|
+
| 'target_unresolvable'
|
|
410
|
+
| 'native_head_unavailable'
|
|
411
|
+
| 'native_thread_changed'
|
|
412
|
+
| 'native_target_busy'
|
|
413
|
+
| 'native_turn_in_progress'
|
|
414
|
+
| 'native_target_fenced'
|
|
415
|
+
| 'attach_failed'
|
|
416
|
+
| 'unknown_binding'
|
|
417
|
+
| 'binding_not_active'
|
|
418
|
+
| 'binding_detached'
|
|
419
|
+
| 'binding_expired'
|
|
420
|
+
| 'stale_epoch'
|
|
421
|
+
| 'target_mismatch'
|
|
422
|
+
| 'binding_unusable'
|
|
423
|
+
| 'pin_failed'
|
|
424
|
+
| 'adapter_unwired'
|
|
425
|
+
| 'provider_never_opened'
|
|
426
|
+
| 'delivery_ambiguous'
|
|
427
|
+
| 'turn_failed'
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Footer copy for the refusals that are not occupancy reasons.
|
|
431
|
+
*
|
|
432
|
+
* Same enforcement as `REASON_COPY`: `Record<…, string>` means a new member of
|
|
433
|
+
* the union cannot ship without copy, and the suite drives every key through a
|
|
434
|
+
* real route so a member that has copy but is unreachable fails too. No slashes
|
|
435
|
+
* in any string, which keeps the "nothing this router returns is path-shaped"
|
|
436
|
+
* assertion a flat substring check.
|
|
437
|
+
*/
|
|
438
|
+
export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, string> = {
|
|
439
|
+
invalid_request:
|
|
440
|
+
'That request was not something COS could read. Nothing was sent.',
|
|
441
|
+
binding_registry_unwired:
|
|
442
|
+
'This build cannot record a continuation, so it will not start one. Fork it instead.',
|
|
443
|
+
binding_registry_degraded:
|
|
444
|
+
'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
|
|
445
|
+
binding_registry_unavailable:
|
|
446
|
+
'COS could not read its own continuation records, so it will not continue this thread. Fork it instead.',
|
|
447
|
+
target_unresolvable:
|
|
448
|
+
'COS could not work out where this thread lives, so it will not continue it. Fork it instead.',
|
|
449
|
+
native_head_unavailable:
|
|
450
|
+
'COS could not read where this thread currently ends, so it will not write to it. Fork it instead.',
|
|
451
|
+
native_thread_changed:
|
|
452
|
+
'This thread changed on your Mac since you attached. Refresh, continue anyway, or fork.',
|
|
453
|
+
native_target_busy:
|
|
454
|
+
'This thread is already attached to another COS chat. Detach that one first.',
|
|
455
|
+
native_turn_in_progress:
|
|
456
|
+
'A COS turn is already running on this thread. Wait for it to finish.',
|
|
457
|
+
native_target_fenced:
|
|
458
|
+
'An earlier turn on this thread may or may not have been delivered. Open the thread on your Mac and check before sending again.',
|
|
459
|
+
attach_failed:
|
|
460
|
+
'COS could not record a continuation for this thread. Fork it instead.',
|
|
461
|
+
unknown_binding:
|
|
462
|
+
'That continuation is no longer on record. Attach again.',
|
|
463
|
+
binding_not_active:
|
|
464
|
+
'That continuation is not live yet. Attach again.',
|
|
465
|
+
binding_detached:
|
|
466
|
+
'That continuation was detached. Attach again.',
|
|
467
|
+
binding_expired:
|
|
468
|
+
'That continuation timed out. Attach again.',
|
|
469
|
+
stale_epoch:
|
|
470
|
+
'This prompt was written against an earlier attach of the same thread. It was not sent. Attach again.',
|
|
471
|
+
target_mismatch:
|
|
472
|
+
'This prompt names a different thread than the continuation it claims. It was not sent.',
|
|
473
|
+
binding_unusable:
|
|
474
|
+
'That continuation cannot run work. Attach again.',
|
|
475
|
+
pin_failed:
|
|
476
|
+
'COS could not hold the continuation open for this turn, so it did not send it.',
|
|
477
|
+
adapter_unwired:
|
|
478
|
+
'This build cannot drive the original thread yet. Fork it instead.',
|
|
479
|
+
provider_never_opened:
|
|
480
|
+
'The assistant never opened the thread, so nothing was sent. You can try again.',
|
|
481
|
+
delivery_ambiguous:
|
|
482
|
+
'COS lost track of this turn after sending it. Open the thread on your Mac and check before sending again.',
|
|
483
|
+
turn_failed:
|
|
484
|
+
'COS could not run this turn. Nothing was sent. You can try again.',
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function writeReasonCopy(reason: WriteRefusal): string {
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(REASON_COPY, reason)) {
|
|
489
|
+
return REASON_COPY[reason as OccupancyReason]
|
|
490
|
+
}
|
|
491
|
+
const copy = Object.prototype.hasOwnProperty.call(WRITE_REASON_COPY, reason)
|
|
492
|
+
? WRITE_REASON_COPY[reason as Exclude<WriteRefusal, OccupancyReason>]
|
|
493
|
+
: undefined
|
|
494
|
+
return typeof copy === 'string' && copy.length > 0 ? copy : UNKNOWN_REASON_COPY
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Refusals that mean "this build or this machine cannot do it", answered 503.
|
|
499
|
+
*
|
|
500
|
+
* Everything else that is not a malformed request is 409: a conflict with the
|
|
501
|
+
* state of the world, which the user can act on. The split lives in one table so
|
|
502
|
+
* the two routes cannot drift, and so a new refusal defaults to 409 — the
|
|
503
|
+
* conservative choice, since a client is far more likely to auto-retry a 503.
|
|
504
|
+
*/
|
|
505
|
+
const CAPABILITY_REFUSALS: ReadonlySet<WriteRefusal> = new Set<WriteRefusal>([
|
|
506
|
+
'detector_unavailable',
|
|
507
|
+
'binding_registry_unwired',
|
|
508
|
+
'binding_registry_degraded',
|
|
509
|
+
'binding_registry_unavailable',
|
|
510
|
+
'adapter_unwired',
|
|
511
|
+
])
|
|
512
|
+
|
|
513
|
+
export function refusalStatus(reason: WriteRefusal): number {
|
|
514
|
+
if (reason === 'invalid_request') return 400
|
|
515
|
+
return CAPABILITY_REFUSALS.has(reason) ? 503 : 409
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Map anything the binding registry can say onto a refusal with copy.
|
|
520
|
+
*
|
|
521
|
+
* `Record<RegistryRejection, WriteRefusal>` is the point: the registry owns that
|
|
522
|
+
* union and can grow it, and this must not compile if it does. Several members
|
|
523
|
+
* are unreachable through these two routes (`invalid_ttl` needs a bad TTL, which
|
|
524
|
+
* this router supplies itself) and collapse onto a generic refusal rather than
|
|
525
|
+
* inventing copy nobody can ever see.
|
|
526
|
+
*/
|
|
527
|
+
export const REGISTRY_REJECTION_REFUSAL: Record<RegistryRejection, WriteRefusal> = {
|
|
528
|
+
// Value-type rejections.
|
|
529
|
+
invalid_thread_id: 'invalid_thread_id',
|
|
530
|
+
invalid_provider: 'unsupported_provider',
|
|
531
|
+
invalid_binding_id: 'unknown_binding',
|
|
532
|
+
invalid_epoch: 'invalid_request',
|
|
533
|
+
invalid_ttl: 'attach_failed',
|
|
534
|
+
unknown_binding: 'unknown_binding',
|
|
535
|
+
binding_not_active: 'binding_not_active',
|
|
536
|
+
binding_detached: 'binding_detached',
|
|
537
|
+
binding_expired: 'binding_expired',
|
|
538
|
+
stale_epoch: 'stale_epoch',
|
|
539
|
+
target_mismatch: 'target_mismatch',
|
|
540
|
+
missing_target_key: 'invalid_request',
|
|
541
|
+
binding_pinned: 'binding_unusable',
|
|
542
|
+
terminal_state: 'binding_unusable',
|
|
543
|
+
// Registry-level rejections.
|
|
544
|
+
store_unavailable: 'binding_registry_degraded',
|
|
545
|
+
persist_failed: 'attach_failed',
|
|
546
|
+
reentrant_mutation: 'attach_failed',
|
|
547
|
+
target_busy: 'native_target_busy',
|
|
548
|
+
binding_id_in_use: 'attach_failed',
|
|
549
|
+
invalid_job_id: 'pin_failed',
|
|
550
|
+
too_many_pins: 'pin_failed',
|
|
551
|
+
registry_full: 'attach_failed',
|
|
552
|
+
epoch_ledger_full: 'attach_failed',
|
|
553
|
+
caller_supplied_epoch: 'attach_failed',
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function registryRefusal(reason: RegistryRejection | null | undefined): WriteRefusal {
|
|
557
|
+
if (typeof reason !== 'string') return 'attach_failed'
|
|
558
|
+
return Object.prototype.hasOwnProperty.call(REGISTRY_REJECTION_REFUSAL, reason)
|
|
559
|
+
? REGISTRY_REJECTION_REFUSAL[reason]
|
|
560
|
+
: 'attach_failed'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Bounded, deterministic, non-identifying stand-in for a provider revision token
|
|
565
|
+
* or a `boundTo` marker.
|
|
566
|
+
*
|
|
567
|
+
* WHY NOT THE RAW VALUES. `boundToMarker` is length-prefixed over
|
|
568
|
+
* bindingId + epoch + targetKey, and targetKey embeds the exact private native
|
|
569
|
+
* thread id — so returning the raw marker would put the native id on the wire
|
|
570
|
+
* through the one route whose entire redaction contract says it never does. The
|
|
571
|
+
* revision token comes from a module this one does not own, and plan 4.3 only
|
|
572
|
+
* ASKS that it carry no content or path; asking is not enforcing. A digest makes
|
|
573
|
+
* both true by construction, stays comparable across requests, and the client can
|
|
574
|
+
* hand it back for the Continue Anyway acknowledgement.
|
|
575
|
+
*
|
|
576
|
+
* NOT A CAPABILITY TOKEN. It is a digest of values the attaching client already
|
|
577
|
+
* holds, so it proves recognisability, not authority. Authorization is
|
|
578
|
+
* `requireApiToken` at the app level, exactly as for every other route here.
|
|
579
|
+
*/
|
|
580
|
+
export function opaqueRevision(value: string): string {
|
|
581
|
+
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Shape of an opaque value coming back from a client. 128 bits of hex, nothing else. */
|
|
585
|
+
export const OPAQUE_RE = /^[0-9a-f]{32}$/
|
|
586
|
+
|
|
587
|
+
export interface AttachabilityBody {
|
|
588
|
+
attachable: boolean
|
|
589
|
+
reason: OccupancyReason | null
|
|
590
|
+
reasonCopy: string
|
|
591
|
+
ownerCount: number
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Project a verdict to the wire, re-checking its internal consistency.
|
|
596
|
+
*
|
|
597
|
+
* The detector is trusted to be correct; it is not trusted to STAY correct. Three
|
|
598
|
+
* shapes are contradictions rather than verdicts, and each one resolves the
|
|
599
|
+
* permissive way if simply forwarded: attachable with a reason, attachable with an
|
|
600
|
+
* owner that is not provably ours, and a non-array owners field. Any of them is a
|
|
601
|
+
* defect upstream, and a defect must not resolve to permissive.
|
|
602
|
+
*/
|
|
603
|
+
export function projectAttachability(verdict: Occupancy): AttachabilityBody {
|
|
604
|
+
const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
|
|
605
|
+
const sound =
|
|
606
|
+
verdict?.attachable === true &&
|
|
607
|
+
verdict.reason === null &&
|
|
608
|
+
owners !== null &&
|
|
609
|
+
owners.every(owner => owner?.selfOwned === true)
|
|
610
|
+
const reason: OccupancyReason | null = sound ? null : ((verdict?.reason ?? 'probe_failed') as OccupancyReason)
|
|
611
|
+
return {
|
|
612
|
+
attachable: sound,
|
|
613
|
+
reason,
|
|
614
|
+
reasonCopy: reasonCopy(sound ? null : reason),
|
|
615
|
+
// Total owners, self-owned included. Reporting only foreign owners would make
|
|
616
|
+
// the count read lower than reality, and this number exists so a client can
|
|
617
|
+
// never be MORE confident than the server. Attachability is `attachable`,
|
|
618
|
+
// never an inference from this field being zero.
|
|
619
|
+
ownerCount: owners === null ? 0 : owners.length,
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Compile-time exhaustive membership test for a binding state. */
|
|
624
|
+
const BINDING_STATES: Record<BindingState, true> = {
|
|
625
|
+
staging: true,
|
|
626
|
+
active: true,
|
|
627
|
+
detaching: true,
|
|
628
|
+
detached: true,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isBindingState(value: unknown): value is BindingState {
|
|
632
|
+
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(BINDING_STATES, value)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Is this registry row trustworthy enough to describe on the wire?
|
|
637
|
+
*
|
|
638
|
+
* Validated with the SHARED id validator, never a local copy: native-thread-id.ts
|
|
639
|
+
* exists because two modules written in the same session disagreed about what an
|
|
640
|
+
* id is, and a truncated id then sailed through occupancy as attachable.
|
|
641
|
+
*/
|
|
642
|
+
export function isUsableBindingRow(value: unknown): value is NativeBinding {
|
|
643
|
+
if (!value || typeof value !== 'object') return false
|
|
644
|
+
const row = value as Partial<NativeBinding>
|
|
645
|
+
if (typeof row.bindingId !== 'string' || !BINDING_ID_RE.test(row.bindingId)) return false
|
|
646
|
+
if (!isBindableProvider(row.provider)) return false
|
|
647
|
+
if (!isValidNativeThreadId(row.nativeThreadId)) return false
|
|
648
|
+
if (!isBindingState(row.state)) return false
|
|
649
|
+
if (!Number.isInteger(row.epoch) || (row.epoch as number) < 1) return false
|
|
650
|
+
if (typeof row.expiresAt !== 'number' || !Number.isFinite(row.expiresAt)) return false
|
|
651
|
+
if (!Array.isArray(row.pinnedJobs)) return false
|
|
652
|
+
return true
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface BindingSummary {
|
|
656
|
+
bindingId: string
|
|
657
|
+
provider: string
|
|
658
|
+
state: BindingState
|
|
659
|
+
epoch: number
|
|
660
|
+
expiresAt: number
|
|
661
|
+
pinned: boolean
|
|
662
|
+
expired: boolean
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Redacted binding row.
|
|
667
|
+
*
|
|
668
|
+
* Dropped on purpose, each because it is either identifying or forgeable:
|
|
669
|
+
* `nativeThreadId` (the private native id), `targetKey` (the mutex key plan 3.3
|
|
670
|
+
* names explicitly), `cosSessionId` (client-supplied, and SAFE_ID_RE permits '/'
|
|
671
|
+
* inside it), both fingerprints (nothing constrains them to be hashes rather than
|
|
672
|
+
* raw paths), `nativeHeadAtAttach` (an opaque revision nobody needs in a list) and
|
|
673
|
+
* `pinnedJobs` (ids, where a boolean answers the only question a list view asks).
|
|
674
|
+
*/
|
|
675
|
+
export function projectBinding(binding: NativeBinding, now: number): BindingSummary {
|
|
676
|
+
return {
|
|
677
|
+
bindingId: binding.bindingId,
|
|
678
|
+
provider: binding.provider,
|
|
679
|
+
state: binding.state,
|
|
680
|
+
epoch: binding.epoch,
|
|
681
|
+
expiresAt: binding.expiresAt,
|
|
682
|
+
pinned: isPinned(binding),
|
|
683
|
+
expired: isExpired(binding, now),
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function occupancyDepsUsable(deps: AgentSessionBindingsDeps): boolean {
|
|
688
|
+
const probes = deps?.probes
|
|
689
|
+
const dirs = deps?.dirs
|
|
690
|
+
return (
|
|
691
|
+
!!probes &&
|
|
692
|
+
typeof probes.dirExists === 'function' &&
|
|
693
|
+
typeof probes.readDir === 'function' &&
|
|
694
|
+
typeof probes.readFile === 'function' &&
|
|
695
|
+
typeof probes.isAlive === 'function' &&
|
|
696
|
+
typeof probes.processStartMs === 'function' &&
|
|
697
|
+
typeof probes.fileExists === 'function' &&
|
|
698
|
+
typeof probes.lockHolders === 'function' &&
|
|
699
|
+
typeof probes.cosSpawnedPids === 'function' &&
|
|
700
|
+
!!dirs &&
|
|
701
|
+
typeof dirs.claudeSessionsDir === 'string' &&
|
|
702
|
+
typeof dirs.codexLocksDir === 'string'
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function bindingDepsUsable(deps: AgentSessionBindingsDeps): boolean {
|
|
707
|
+
return (
|
|
708
|
+
!!deps?.bindings &&
|
|
709
|
+
typeof deps.bindings.list === 'function' &&
|
|
710
|
+
typeof deps.bindings.available === 'function' &&
|
|
711
|
+
typeof deps?.now === 'function'
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Can this build mint and drive a binding at all, or only describe one? */
|
|
716
|
+
function bindingWriteDepsUsable(deps: AgentSessionBindingsDeps): boolean {
|
|
717
|
+
const b = deps?.bindings
|
|
718
|
+
return (
|
|
719
|
+
bindingDepsUsable(deps) &&
|
|
720
|
+
typeof b?.create === 'function' &&
|
|
721
|
+
typeof b?.activate === 'function' &&
|
|
722
|
+
typeof b?.forceDetach === 'function' &&
|
|
723
|
+
typeof b?.get === 'function' &&
|
|
724
|
+
typeof b?.checkQueuedPrompt === 'function' &&
|
|
725
|
+
typeof b?.pin === 'function' &&
|
|
726
|
+
typeof b?.unpin === 'function'
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export const DEFAULT_ATTACH_TTL_MS = 30 * 60_000
|
|
731
|
+
export const DEFAULT_MAX_PROMPT_CHARS = 32_000
|
|
732
|
+
/** Bindings whose advanced head is remembered. See `acknowledgeHead`. */
|
|
733
|
+
export const MAX_TRACKED_HEADS = 512
|
|
734
|
+
|
|
735
|
+
/** A COS session id may contain ':' and '/', which is exactly why it is never projected. */
|
|
736
|
+
export const COS_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The in-process half of plan 4.5 and 4.6: one COS turn per native target, and a
|
|
740
|
+
* target that may already hold an undelivered turn stays shut.
|
|
741
|
+
*
|
|
742
|
+
* NOT DURABLE, AND THAT IS A STATED GAP. Plan 4.5 wants the reservation
|
|
743
|
+
* persisted in the job journal and rehydrated on boot, and 4.6 item 3 wants the
|
|
744
|
+
* fence to have its own lifecycle and an operator release path. Both belong to
|
|
745
|
+
* Phase 2, which owns that journal. What lives here is the process-lifetime
|
|
746
|
+
* version, which is enough to make the two properties true for a running server
|
|
747
|
+
* and fails in the safe direction on restart: a claim is released (no turn is
|
|
748
|
+
* running after a restart anyway) and a FENCE is lost, which is the one that
|
|
749
|
+
* matters and is why it is called out rather than implied.
|
|
750
|
+
*/
|
|
751
|
+
class TargetGuard {
|
|
752
|
+
/** targetKey -> turnId of the single COS turn allowed to be in flight. */
|
|
753
|
+
private readonly claims = new Map<string, string>()
|
|
754
|
+
/** targetKey -> why no further turn may be delivered. */
|
|
755
|
+
private readonly fences = new Map<string, WriteRefusal>()
|
|
756
|
+
/** bindingId -> the head digest this binding is currently reconciled to. */
|
|
757
|
+
private readonly heads = new Map<string, string>()
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Check and set in ONE synchronous step.
|
|
761
|
+
*
|
|
762
|
+
* Plan 4.5 requirement 1 is a "synchronous, non-blocking target reservation
|
|
763
|
+
* check" that returns immediately, and the atomicity is load-bearing: with an
|
|
764
|
+
* await between the read and the write, two turns admitted in the same tick
|
|
765
|
+
* both see a free target. Every caller must reach this before its first await.
|
|
766
|
+
*/
|
|
767
|
+
tryClaim(targetKey: string, turnId: string): boolean {
|
|
768
|
+
if (this.claims.has(targetKey)) return false
|
|
769
|
+
this.claims.set(targetKey, turnId)
|
|
770
|
+
return true
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Only the holder may release, so a late unwind cannot free someone else's claim. */
|
|
774
|
+
release(targetKey: string, turnId: string): void {
|
|
775
|
+
if (this.claims.get(targetKey) === turnId) this.claims.delete(targetKey)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
fence(targetKey: string, reason: WriteRefusal): void {
|
|
779
|
+
if (!this.fences.has(targetKey)) this.fences.set(targetKey, reason)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
fencedReason(targetKey: string): WriteRefusal | null {
|
|
783
|
+
return this.fences.get(targetKey) ?? null
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The head this binding is reconciled to: the attach baseline, then whatever
|
|
788
|
+
* the user acknowledged or a completed turn produced.
|
|
789
|
+
*
|
|
790
|
+
* Without this the SECOND turn on a binding always fails the divergence check,
|
|
791
|
+
* because the first turn moved the head itself.
|
|
792
|
+
*/
|
|
793
|
+
acknowledgedHead(bindingId: string): string | null {
|
|
794
|
+
return this.heads.get(bindingId) ?? null
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
acknowledgeHead(bindingId: string, digest: string): void {
|
|
798
|
+
// Re-insert so the eviction order is by last use.
|
|
799
|
+
this.heads.delete(bindingId)
|
|
800
|
+
this.heads.set(bindingId, digest)
|
|
801
|
+
while (this.heads.size > MAX_TRACKED_HEADS) {
|
|
802
|
+
const oldest = this.heads.keys().next()
|
|
803
|
+
if (oldest.done) break
|
|
804
|
+
this.heads.delete(oldest.value)
|
|
805
|
+
}
|
|
806
|
+
// Eviction here is safe in a way eviction almost never is in this feature:
|
|
807
|
+
// losing an advance makes the next turn fall back to the ATTACH baseline, so
|
|
808
|
+
// it sees a changed head and asks the user to acknowledge. Strictly more
|
|
809
|
+
// conservative, never less.
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
type Delivery =
|
|
814
|
+
| { kind: 'completed'; after: string | null }
|
|
815
|
+
| { kind: 'aborted' }
|
|
816
|
+
| { kind: 'ambiguous' }
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Read an adapter result without believing anything it did not say.
|
|
820
|
+
*
|
|
821
|
+
* The default is ambiguous, and every unrecognised shape lands there: null, an
|
|
822
|
+
* array, a missing status, a status from a newer adapter. Only the two literals
|
|
823
|
+
* this build understands are allowed to mean anything.
|
|
824
|
+
*/
|
|
825
|
+
export function classifyDelivery(result: unknown): Delivery {
|
|
826
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
|
|
827
|
+
const status = (result as { status?: unknown }).status
|
|
828
|
+
if (status === 'completed') {
|
|
829
|
+
const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
|
|
830
|
+
return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
|
|
831
|
+
}
|
|
832
|
+
if (status === 'aborted') return { kind: 'aborted' }
|
|
833
|
+
|
|
834
|
+
// The adapter module's shape. Read only when `status` said nothing, so a result
|
|
835
|
+
// carrying both is decided by exactly one rule.
|
|
836
|
+
const { ok, delivery } = result as { ok?: unknown; delivery?: unknown }
|
|
837
|
+
if (typeof ok === 'boolean') {
|
|
838
|
+
// Success is not inferred from `ok` alone: a truthy result whose delivery
|
|
839
|
+
// state is anything but `delivered` is a contradiction, and a contradiction
|
|
840
|
+
// is ambiguous rather than done.
|
|
841
|
+
if (ok === true && delivery === 'delivered') {
|
|
842
|
+
const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
|
|
843
|
+
return { kind: 'completed', after: typeof after === 'string' && after.length > 0 ? after : null }
|
|
844
|
+
}
|
|
845
|
+
if (ok === false && (delivery === 'not_attempted' || delivery === 'aborted')) return { kind: 'aborted' }
|
|
846
|
+
}
|
|
847
|
+
return { kind: 'ambiguous' }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export function isOpaque(value: unknown): value is string {
|
|
851
|
+
return typeof value === 'string' && OPAQUE_RE.test(value)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Both fingerprints present, bounded, and strings.
|
|
856
|
+
*
|
|
857
|
+
* They are persisted into the binding and handed to the adapter, and nothing in
|
|
858
|
+
* the type says they are hashes rather than raw paths — the store's own header
|
|
859
|
+
* says so. Bounded because they become JSON in a durable file; never projected,
|
|
860
|
+
* which is why the shape check is all that is needed here.
|
|
861
|
+
*/
|
|
862
|
+
export function isUsableResolution(value: unknown): value is TargetResolution {
|
|
863
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
864
|
+
const { workspaceFingerprint: w, sourceFingerprint: s } = value as Partial<TargetResolution>
|
|
865
|
+
return (
|
|
866
|
+
typeof w === 'string' && w.length > 0 && w.length <= 1024 &&
|
|
867
|
+
typeof s === 'string' && s.length > 0 && s.length <= 1024
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Parsed body, or null for anything that is not a JSON object — including an unparsed body. */
|
|
872
|
+
function plainBody(req: Request): Record<string, unknown> | null {
|
|
873
|
+
const body: unknown = (req as { body?: unknown }).body
|
|
874
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null
|
|
875
|
+
return body as Record<string, unknown>
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export const ATTACHED_COPY = 'Attached. COS is driving the original thread.'
|
|
879
|
+
export const TURN_SENT_COPY = 'Sent to the original thread.'
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Is writing into a native desktop thread turned on?
|
|
883
|
+
*
|
|
884
|
+
* OFF BY DEFAULT, permanently and by design (plan 4.9). A user who never sets this
|
|
885
|
+
* gets exactly the behavior that existed before this feature: read-only browsing
|
|
886
|
+
* and Fork-only everywhere. That is a supported end state, not a migration step.
|
|
887
|
+
*
|
|
888
|
+
* Same shape as `claudeSessionsEnabled()` in routes/claude-sessions.ts rather than
|
|
889
|
+
* a second flag pattern. Strict `=== '1'`, so any other value including 'true'
|
|
890
|
+
* reads as OFF - a feature that writes into a human's conversation should be hard
|
|
891
|
+
* to turn on by accident.
|
|
892
|
+
*/
|
|
893
|
+
/** Client-supplied idempotency key. Long enough that a collision is deliberate. */
|
|
894
|
+
export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
|
|
895
|
+
|
|
896
|
+
export function threadAttachEnabled(): boolean {
|
|
897
|
+
return process.env.COS_THREAD_ATTACH_ENABLED === '1'
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps): Router {
|
|
901
|
+
const router = Router()
|
|
902
|
+
// Evaluated once at wiring time. Incomplete dependencies are a wiring bug, and
|
|
903
|
+
// the safe response to a wiring bug is a route that refuses with a reason, not
|
|
904
|
+
// a server that will not boot and not a route that guesses.
|
|
905
|
+
// Resolved once at wiring time, like canDetect/canListBindings. Injectable so a
|
|
906
|
+
// test can drive both states without mutating process.env.
|
|
907
|
+
const attachEnabled = deps?.attachEnabled ?? threadAttachEnabled()
|
|
908
|
+
const canDetect = occupancyDepsUsable(deps)
|
|
909
|
+
const canListBindings = bindingDepsUsable(deps)
|
|
910
|
+
const canWriteBindings = bindingWriteDepsUsable(deps)
|
|
911
|
+
const detect = deps?.occupancy ?? threadOccupancy
|
|
912
|
+
const guard = new TargetGuard()
|
|
913
|
+
const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
|
|
914
|
+
const attachTtlMs =
|
|
915
|
+
Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
|
|
916
|
+
? (deps.attachTtlMs as number)
|
|
917
|
+
: DEFAULT_ATTACH_TTL_MS
|
|
918
|
+
const maxPromptChars =
|
|
919
|
+
Number.isInteger(deps?.maxPromptChars) && (deps.maxPromptChars as number) > 0
|
|
920
|
+
? (deps.maxPromptChars as number)
|
|
921
|
+
: DEFAULT_MAX_PROMPT_CHARS
|
|
922
|
+
const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* One owner for "is this thread free right now", shared by the probe and both
|
|
926
|
+
* writes, including its own try/catch and the self-contradiction re-check.
|
|
927
|
+
*
|
|
928
|
+
* Sharing it is the point. Provider and id validation, the reason precedence
|
|
929
|
+
* between them, and the refusal to trust an unsound verdict all live in exactly
|
|
930
|
+
* one place, so the write routes cannot come to a different conclusion than the
|
|
931
|
+
* probe the user was shown a second earlier.
|
|
932
|
+
*/
|
|
933
|
+
const runOccupancy = (provider: string, threadId: string): AttachabilityBody => {
|
|
934
|
+
let verdict: Occupancy
|
|
935
|
+
try {
|
|
936
|
+
verdict = detect(provider, threadId, deps.probes, deps.dirs)
|
|
937
|
+
} catch (error) {
|
|
938
|
+
// `threadOccupancy` already contains its own probe try/catch, so reaching
|
|
939
|
+
// here means the detector itself threw. Logged without the thread id.
|
|
940
|
+
console.error(`[agent-session-bindings] occupancy threw: ${error instanceof Error ? error.message : error}`)
|
|
941
|
+
verdict = { attachable: false, owners: [], reason: 'probe_failed' }
|
|
942
|
+
}
|
|
943
|
+
return projectAttachability(verdict)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
947
|
+
res.status(refusalStatus(reason)).json({
|
|
948
|
+
attached: false,
|
|
949
|
+
reason,
|
|
950
|
+
reasonCopy: writeReasonCopy(reason),
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Read a finite clock, or null. A route that cannot tell the time refuses. */
|
|
955
|
+
const readNow = (): number | null => {
|
|
956
|
+
try {
|
|
957
|
+
const value = deps.now()
|
|
958
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
959
|
+
} catch (error) {
|
|
960
|
+
console.error(`[agent-session-bindings] clock threw: ${error instanceof Error ? error.message : error}`)
|
|
961
|
+
return null
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Current head as an opaque digest, or null when it could not be established.
|
|
967
|
+
*
|
|
968
|
+
* Null covers three different upstream events on purpose — the module is
|
|
969
|
+
* unwired, it answered null, or it threw — because all three leave this router
|
|
970
|
+
* without a baseline, and without a baseline plan 4.3 has no divergence check
|
|
971
|
+
* at all. The raw token is returned alongside so the adapter can be handed it;
|
|
972
|
+
* only the digest ever reaches the wire.
|
|
973
|
+
*/
|
|
974
|
+
const readHead = async (
|
|
975
|
+
provider: BindableProvider,
|
|
976
|
+
threadId: string,
|
|
977
|
+
): Promise<{ raw: string; digest: string } | null> => {
|
|
978
|
+
const read = deps.nativeHead
|
|
979
|
+
if (typeof read !== 'function') return null
|
|
980
|
+
try {
|
|
981
|
+
const raw = await read(provider, threadId)
|
|
982
|
+
if (typeof raw !== 'string' || raw.length === 0) return null
|
|
983
|
+
return { raw, digest: opaqueRevision(raw) }
|
|
984
|
+
} catch (error) {
|
|
985
|
+
console.error(`[agent-session-bindings] native head threw: ${error instanceof Error ? error.message : error}`)
|
|
986
|
+
return null
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
router.get('/agent-sessions/:provider/:threadId/attachability', (req, res) => {
|
|
991
|
+
// An occupancy verdict is a liveness answer with a lifetime of roughly now.
|
|
992
|
+
// A cached `attachable: true` is indistinguishable from a stale one, which is
|
|
993
|
+
// the whole failure this feature exists to prevent.
|
|
994
|
+
res.set('Cache-Control', 'private, no-store')
|
|
995
|
+
|
|
996
|
+
// Before `canDetect` and before any probe. With no write path there is nothing
|
|
997
|
+
// to protect against, so a disabled install does no filesystem work and cannot
|
|
998
|
+
// fail. It also keeps the surface self-consistent: reporting `attachable: true`
|
|
999
|
+
// while the attach route is unrouted would leave a client unable to tell
|
|
1000
|
+
// whether the thread is free or the feature is off.
|
|
1001
|
+
if (!attachEnabled) {
|
|
1002
|
+
res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (!canDetect) {
|
|
1007
|
+
// The mechanism does not exist on this install. Distinct from "it ran and
|
|
1008
|
+
// found nothing" by design (plan 4.3 wants the reason nameable).
|
|
1009
|
+
res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 200 for every verdict, including a bad provider or a malformed id.
|
|
1014
|
+
//
|
|
1015
|
+
// The route's job is to answer "can I attach?", and "no, because that is not a
|
|
1016
|
+
// provider COS can continue" is an answer the footer can render. Following
|
|
1017
|
+
// claude-sessions.ts, which returns 200 for the switched-off case for the same
|
|
1018
|
+
// reason: a client that gets a 4xx has to invent copy, and invented copy is
|
|
1019
|
+
// where "unavailable" quietly becomes "try anyway".
|
|
1020
|
+
//
|
|
1021
|
+
// Provider and id are validated by `threadOccupancy` BEFORE it touches a
|
|
1022
|
+
// filesystem, in that order. Re-checking them here would be a second copy of a
|
|
1023
|
+
// rule that already has one owner, and the two copies are exactly how the
|
|
1024
|
+
// truncated-id hole opened. The tests pin the ordering behaviorally instead:
|
|
1025
|
+
// probes that throw on every call still return `unsupported_provider` /
|
|
1026
|
+
// `invalid_thread_id`, which is only possible if nothing was probed.
|
|
1027
|
+
res.json(runOccupancy(String(req.params.provider ?? ''), String(req.params.threadId ?? '')))
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
router.get('/agent-sessions/bindings', (_req, res) => {
|
|
1031
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1032
|
+
|
|
1033
|
+
const unavailable = (reason: string) => {
|
|
1034
|
+
// 503, not an empty 200. An empty `bindings` array from this route must
|
|
1035
|
+
// always mean "proved there are none"; if it could also mean "could not
|
|
1036
|
+
// look", every caller inherits the absence-inference bug.
|
|
1037
|
+
res.status(503).json({ bindings: [], available: false, reason, generatedAt: null })
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Distinct from `binding_registry_unavailable` on purpose, and the mutation
|
|
1041
|
+
// pass is why. With one shared reason this branch had NO observable behavior:
|
|
1042
|
+
// an unwired registry threw inside the try below and produced the identical
|
|
1043
|
+
// response, so deleting the gate changed nothing and the guard was decoration.
|
|
1044
|
+
// Separating them gives it a job worth testing and answers the question an
|
|
1045
|
+
// operator actually has: a build was wired wrong, not a disk that failed.
|
|
1046
|
+
if (!canListBindings) {
|
|
1047
|
+
unavailable('binding_registry_unwired')
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
let rows: readonly NativeBinding[]
|
|
1052
|
+
let now: number
|
|
1053
|
+
try {
|
|
1054
|
+
// Asked BEFORE the list, because a degraded store answers `list()` with an
|
|
1055
|
+
// empty array rather than an error. Anything other than an explicit `true`
|
|
1056
|
+
// is unusable: a probe that answers "maybe" is answering no.
|
|
1057
|
+
if (deps.bindings.available() !== true) {
|
|
1058
|
+
unavailable('binding_registry_degraded')
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
rows = deps.bindings.list()
|
|
1062
|
+
now = deps.now()
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
console.error(`[agent-session-bindings] binding list failed: ${error instanceof Error ? error.message : error}`)
|
|
1065
|
+
unavailable('binding_registry_unavailable')
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (!Array.isArray(rows) || typeof now !== 'number' || !Number.isFinite(now)) {
|
|
1070
|
+
unavailable('binding_registry_unavailable')
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const bindings: BindingSummary[] = []
|
|
1075
|
+
for (const row of rows) {
|
|
1076
|
+
// A malformed row makes the WHOLE listing unavailable rather than a silently
|
|
1077
|
+
// shorter one. A list that quietly drops the row it could not parse tells the
|
|
1078
|
+
// operator a binding is gone when it may be live and holding a target.
|
|
1079
|
+
if (!isUsableBindingRow(row)) {
|
|
1080
|
+
unavailable('binding_registry_unreadable')
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
if (isTerminal(row)) continue
|
|
1084
|
+
bindings.push(projectBinding(row, now))
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
res.json({ bindings, available: true, reason: null, generatedAt: now })
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
// ---------------------------------------------------------------- attach
|
|
1091
|
+
//
|
|
1092
|
+
// Registered before the turns route only for tidiness; the two paths end in
|
|
1093
|
+
// different literal segments (`attach` vs `turns`) so neither can shadow the
|
|
1094
|
+
// other, and neither can shadow `agentSessionsRouter`'s two-segment GETs.
|
|
1095
|
+
// Registered ONLY when the feature is on. Not a handler that declines - an
|
|
1096
|
+
// unregistered path 404s, so a disabled server holds no reachable write code.
|
|
1097
|
+
if (attachEnabled) router.post('/agent-sessions/:provider/:threadId/attach', async (req, res) => {
|
|
1098
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1099
|
+
try {
|
|
1100
|
+
if (!canDetect) return refuseAttach(res, 'detector_unavailable')
|
|
1101
|
+
if (!canWriteBindings) return refuseAttach(res, 'binding_registry_unwired')
|
|
1102
|
+
|
|
1103
|
+
// The only field the client may send. Plan 4.2: no path, cwd, executable,
|
|
1104
|
+
// model, target key, permission mode or credentials — the server resolves
|
|
1105
|
+
// every execution field itself. Extra keys are ignored rather than rejected
|
|
1106
|
+
// so a newer client cannot be broken by an older server, but nothing outside
|
|
1107
|
+
// this one field is ever read.
|
|
1108
|
+
const body = plainBody(req)
|
|
1109
|
+
const cosSessionId = body?.cosSessionId
|
|
1110
|
+
if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
|
|
1111
|
+
return refuseAttach(res, 'invalid_request')
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const now = readNow()
|
|
1115
|
+
if (now === null) return refuseAttach(res, 'binding_registry_unavailable')
|
|
1116
|
+
|
|
1117
|
+
let available: boolean
|
|
1118
|
+
try {
|
|
1119
|
+
available = deps.bindings.available() === true
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
|
|
1122
|
+
return refuseAttach(res, 'binding_registry_unavailable')
|
|
1123
|
+
}
|
|
1124
|
+
if (!available) return refuseAttach(res, 'binding_registry_degraded')
|
|
1125
|
+
|
|
1126
|
+
const providerParam = String(req.params.provider ?? '')
|
|
1127
|
+
const threadIdParam = String(req.params.threadId ?? '')
|
|
1128
|
+
|
|
1129
|
+
// THE HARD PRECONDITION (plan 4.3, option B). Not advisory, not a warning,
|
|
1130
|
+
// and not a field on a successful response: a thread with any live owner —
|
|
1131
|
+
// or any doubt about whether it has one — is Fork-only, and attach is the
|
|
1132
|
+
// gate that makes that true.
|
|
1133
|
+
const verdict = runOccupancy(providerParam, threadIdParam)
|
|
1134
|
+
if (!verdict.attachable) return refuseAttach(res, verdict.reason ?? 'probe_failed')
|
|
1135
|
+
|
|
1136
|
+
// `runOccupancy` already proved both, provider first. These narrow the types
|
|
1137
|
+
// rather than re-deciding the rule — a second copy of the id rule is how the
|
|
1138
|
+
// truncated-id hole opened. A failure here is a self-contradicting detector,
|
|
1139
|
+
// and a contradiction refuses.
|
|
1140
|
+
if (!isBindableProvider(providerParam)) return refuseAttach(res, 'unsupported_provider')
|
|
1141
|
+
if (!isValidNativeThreadId(threadIdParam)) return refuseAttach(res, 'invalid_thread_id')
|
|
1142
|
+
|
|
1143
|
+
const key = targetKey(providerParam, threadIdParam)
|
|
1144
|
+
const fenced = guard.fencedReason(key)
|
|
1145
|
+
// A target holding a turn that may already have been delivered does not open
|
|
1146
|
+
// again just because the binding that delivered it is gone. Checked here as
|
|
1147
|
+
// well as in the turn route, because a fresh attach is the obvious way around
|
|
1148
|
+
// a per-binding fence.
|
|
1149
|
+
if (fenced !== null) return refuseAttach(res, fenced)
|
|
1150
|
+
|
|
1151
|
+
const resolve = deps.resolveTarget
|
|
1152
|
+
if (typeof resolve !== 'function') return refuseAttach(res, 'target_unresolvable')
|
|
1153
|
+
let resolved: TargetResolution | null = null
|
|
1154
|
+
try {
|
|
1155
|
+
resolved = resolve(providerParam, threadIdParam)
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
console.error(`[agent-session-bindings] target resolve threw: ${error instanceof Error ? error.message : error}`)
|
|
1158
|
+
resolved = null
|
|
1159
|
+
}
|
|
1160
|
+
if (!isUsableResolution(resolved)) return refuseAttach(res, 'target_unresolvable')
|
|
1161
|
+
|
|
1162
|
+
// The divergence baseline. No baseline, no attach: plan 4.3 is the only
|
|
1163
|
+
// thing that makes a desktop edit visible, and a binding that cannot run it
|
|
1164
|
+
// would be a binding whose every turn is unchecked.
|
|
1165
|
+
const head = await readHead(providerParam, threadIdParam)
|
|
1166
|
+
if (head === null) return refuseAttach(res, 'native_head_unavailable')
|
|
1167
|
+
|
|
1168
|
+
const bindingId = `bnd-${mintId()}`
|
|
1169
|
+
if (!BINDING_ID_RE.test(bindingId)) return refuseAttach(res, 'attach_failed')
|
|
1170
|
+
|
|
1171
|
+
// `create` reads the epoch from the DURABLE per-target high-water ledger and
|
|
1172
|
+
// refuses a caller-supplied one. Passing `priorEpoch` from a live binding —
|
|
1173
|
+
// the shortcut this deliberately cannot express — is what reopens the replay
|
|
1174
|
+
// window after a detach and eviction. It also enforces one non-terminal
|
|
1175
|
+
// binding per target: a second attach gets `target_busy`, never a re-bind.
|
|
1176
|
+
const created = deps.bindings.create!({
|
|
1177
|
+
bindingId,
|
|
1178
|
+
cosSessionId,
|
|
1179
|
+
provider: providerParam,
|
|
1180
|
+
nativeThreadId: threadIdParam,
|
|
1181
|
+
workspaceFingerprint: resolved!.workspaceFingerprint,
|
|
1182
|
+
sourceFingerprint: resolved!.sourceFingerprint,
|
|
1183
|
+
nativeHeadAtAttach: head.digest,
|
|
1184
|
+
ttlMs: attachTtlMs,
|
|
1185
|
+
now,
|
|
1186
|
+
})
|
|
1187
|
+
if (!created?.binding) return refuseAttach(res, registryRefusal(created?.reason))
|
|
1188
|
+
|
|
1189
|
+
const activated = deps.bindings.activate!(bindingId, now)
|
|
1190
|
+
if (!activated?.binding) {
|
|
1191
|
+
// A staging binding still HOLDS the target. Leaving it there would make
|
|
1192
|
+
// every later attach to this thread fail `target_busy` until the lease
|
|
1193
|
+
// expired, with nothing driving it and nothing to detach.
|
|
1194
|
+
try {
|
|
1195
|
+
deps.bindings.forceDetach!(bindingId, now)
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
console.error(`[agent-session-bindings] rollback failed: ${error instanceof Error ? error.message : error}`)
|
|
1198
|
+
}
|
|
1199
|
+
return refuseAttach(res, registryRefusal(activated?.reason))
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
res.status(201).json({
|
|
1203
|
+
attached: true,
|
|
1204
|
+
reason: null,
|
|
1205
|
+
reasonCopy: ATTACHED_COPY,
|
|
1206
|
+
bindingId,
|
|
1207
|
+
epoch: activated.binding.epoch,
|
|
1208
|
+
// Digest, not the marker. `boundToMarker` embeds the targetKey, which
|
|
1209
|
+
// embeds the exact private native id, and this router does not put that on
|
|
1210
|
+
// the wire. The digest is deterministic, so the client can hand it back and
|
|
1211
|
+
// the server recomputes it.
|
|
1212
|
+
boundTo: opaqueRevision(boundToMarker(activated.binding)),
|
|
1213
|
+
revision: head.digest,
|
|
1214
|
+
binding: projectBinding(activated.binding, now),
|
|
1215
|
+
})
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
console.error(`[agent-session-bindings] attach failed: ${error instanceof Error ? error.message : error}`)
|
|
1218
|
+
if (!res.headersSent) refuseAttach(res, 'attach_failed')
|
|
1219
|
+
}
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
// ----------------------------------------------------------------- turns
|
|
1223
|
+
if (attachEnabled) router.post('/agent-sessions/bindings/:bindingId/turns', async (req, res) => {
|
|
1224
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1225
|
+
|
|
1226
|
+
const turnId = mintId()
|
|
1227
|
+
/** The target we hold a claim on, released in the finally. */
|
|
1228
|
+
let claimedKey: string | null = null
|
|
1229
|
+
/** Children the adapter reported, released in the finally. */
|
|
1230
|
+
const recordedPids: number[] = []
|
|
1231
|
+
let pinnedBindingId: string | null = null
|
|
1232
|
+
let requestNow = 0
|
|
1233
|
+
/**
|
|
1234
|
+
* Has the adapter been ENTERED? Everything after this point is ambiguous on
|
|
1235
|
+
* a throw; everything before it is provably undelivered.
|
|
1236
|
+
*/
|
|
1237
|
+
let deliveryAttempted = false
|
|
1238
|
+
/** Client idempotency key and its binding. Null until the body is validated. */
|
|
1239
|
+
let clientTurnId: string | null = null
|
|
1240
|
+
let ledgerBindingId: string | null = null
|
|
1241
|
+
|
|
1242
|
+
const respond = (status: number, payload: Record<string, unknown>): void => {
|
|
1243
|
+
if (!res.headersSent) res.status(status).json(payload)
|
|
1244
|
+
// Remembered ONLY when the prompt may have reached the provider. A
|
|
1245
|
+
// pre-delivery refusal (stale epoch, malformed body, busy target) must stay
|
|
1246
|
+
// re-evaluatable: the binding may be fine by the time the client retries, and
|
|
1247
|
+
// replaying a stale "no" would be its own bug.
|
|
1248
|
+
//
|
|
1249
|
+
// `completed` and `ambiguous` are the two that must never run twice. Measured
|
|
1250
|
+
// 2026-08-16: two byte-identical POSTs both returned completed and the user's
|
|
1251
|
+
// real transcript ended up with two copies of the turn.
|
|
1252
|
+
const outcome = payload.outcome
|
|
1253
|
+
if (clientTurnId !== null && ledgerBindingId !== null && (outcome === 'completed' || outcome === 'ambiguous')) {
|
|
1254
|
+
try {
|
|
1255
|
+
deps.bindings.recordTurn?.(ledgerBindingId, clientTurnId, { ...payload, status }, readNow() ?? requestNow)
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
// A ledger failure must not turn a delivered turn into an error response.
|
|
1258
|
+
// The cost is a lost idempotency record, never a lost turn.
|
|
1259
|
+
console.error(`[agent-session-bindings] turn ledger write failed: ${error instanceof Error ? error.message : error}`)
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const refuseTurn = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
|
|
1264
|
+
respond(refusalStatus(reason), {
|
|
1265
|
+
turnId,
|
|
1266
|
+
outcome: 'refused',
|
|
1267
|
+
// Every refusal below is reached BEFORE the adapter is entered, so this
|
|
1268
|
+
// default is a statement of fact, not an optimistic guess. The two paths
|
|
1269
|
+
// that cannot say it — the ambiguous outcome and the fence — override it.
|
|
1270
|
+
deliveryState: 'not_delivered',
|
|
1271
|
+
retryable: true,
|
|
1272
|
+
changed: false,
|
|
1273
|
+
revision: null,
|
|
1274
|
+
reason,
|
|
1275
|
+
reasonCopy: writeReasonCopy(reason),
|
|
1276
|
+
...extra,
|
|
1277
|
+
})
|
|
1278
|
+
}
|
|
1279
|
+
const reportAmbiguous = (): void => {
|
|
1280
|
+
respond(refusalStatus('delivery_ambiguous'), {
|
|
1281
|
+
turnId,
|
|
1282
|
+
outcome: 'ambiguous',
|
|
1283
|
+
// The whole point. "I did not see it land" is not "it did not land", so a
|
|
1284
|
+
// client must never read this as a failure it may retry.
|
|
1285
|
+
deliveryState: 'unknown',
|
|
1286
|
+
retryable: false,
|
|
1287
|
+
changed: false,
|
|
1288
|
+
revision: null,
|
|
1289
|
+
reason: 'delivery_ambiguous',
|
|
1290
|
+
reasonCopy: writeReasonCopy('delivery_ambiguous'),
|
|
1291
|
+
})
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
try {
|
|
1295
|
+
if (!canDetect) return refuseTurn('detector_unavailable')
|
|
1296
|
+
if (!canWriteBindings) return refuseTurn('binding_registry_unwired')
|
|
1297
|
+
const deliver = deps.deliverAttachedTurn
|
|
1298
|
+
if (typeof deliver !== 'function') return refuseTurn('adapter_unwired')
|
|
1299
|
+
if (typeof deps.nativeHead !== 'function') return refuseTurn('native_head_unavailable')
|
|
1300
|
+
if (typeof ownership?.record !== 'function' || typeof ownership?.release !== 'function') {
|
|
1301
|
+
// Without the ledger a spawned child cannot be recognised as ours, so the
|
|
1302
|
+
// next occupancy check reads it as a live foreign owner and the thread
|
|
1303
|
+
// locks itself out. Refusing beats delivering a turn that poisons the next.
|
|
1304
|
+
return refuseTurn('adapter_unwired')
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const now = readNow()
|
|
1308
|
+
if (now === null) return refuseTurn('binding_registry_unavailable')
|
|
1309
|
+
requestNow = now
|
|
1310
|
+
|
|
1311
|
+
let available: boolean
|
|
1312
|
+
try {
|
|
1313
|
+
available = deps.bindings.available() === true
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
console.error(`[agent-session-bindings] availability threw: ${error instanceof Error ? error.message : error}`)
|
|
1316
|
+
return refuseTurn('binding_registry_unavailable')
|
|
1317
|
+
}
|
|
1318
|
+
if (!available) return refuseTurn('binding_registry_degraded')
|
|
1319
|
+
|
|
1320
|
+
const bindingId = String(req.params.bindingId ?? '')
|
|
1321
|
+
// A malformed id names no binding. Answered as `unknown_binding` rather than
|
|
1322
|
+
// a distinct shape so a prober cannot tell "wrong format" from "no such
|
|
1323
|
+
// lease".
|
|
1324
|
+
if (!BINDING_ID_RE.test(bindingId)) return refuseTurn('unknown_binding')
|
|
1325
|
+
|
|
1326
|
+
const body = plainBody(req)
|
|
1327
|
+
// Covers the case where no JSON parser ran at all: an unparsed body is not
|
|
1328
|
+
// an empty one, and must not fall through as a turn with no claims to check.
|
|
1329
|
+
if (body === null) return refuseTurn('invalid_request')
|
|
1330
|
+
|
|
1331
|
+
const prompt = body.prompt
|
|
1332
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
|
|
1333
|
+
return refuseTurn('invalid_request')
|
|
1334
|
+
}
|
|
1335
|
+
const epoch = body.epoch
|
|
1336
|
+
if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
|
|
1337
|
+
const claimedTargetKey = body.targetKey
|
|
1338
|
+
if (typeof claimedTargetKey !== 'string' || claimedTargetKey.length === 0 || claimedTargetKey.length > 512) {
|
|
1339
|
+
return refuseTurn('invalid_request')
|
|
1340
|
+
}
|
|
1341
|
+
const acknowledged = body.acknowledgedRevision
|
|
1342
|
+
if (acknowledged !== undefined && acknowledged !== null && !isOpaque(acknowledged)) {
|
|
1343
|
+
return refuseTurn('invalid_request')
|
|
1344
|
+
}
|
|
1345
|
+
const boundTo = body.boundTo
|
|
1346
|
+
if (boundTo !== undefined && boundTo !== null && !isOpaque(boundTo)) return refuseTurn('invalid_request')
|
|
1347
|
+
|
|
1348
|
+
// REQUIRED, not optional. A turn with no idempotency key cannot be made safe:
|
|
1349
|
+
// the client cannot tell "delivered but the 200 was lost" from "never
|
|
1350
|
+
// arrived", so it will retry, and without a key the server cannot tell that
|
|
1351
|
+
// retry from a new turn. Required rather than defaulted because there are no
|
|
1352
|
+
// existing callers to break - the feature ships dark.
|
|
1353
|
+
const submitted = body.clientTurnId
|
|
1354
|
+
if (typeof submitted !== 'string' || !CLIENT_TURN_ID_RE.test(submitted)) {
|
|
1355
|
+
return refuseTurn('invalid_request')
|
|
1356
|
+
}
|
|
1357
|
+
clientTurnId = submitted
|
|
1358
|
+
ledgerBindingId = bindingId
|
|
1359
|
+
|
|
1360
|
+
// REPLAY, before any occupancy check, head read, or spawn: if this exact turn
|
|
1361
|
+
// already reached a terminal state, hand back what it actually did.
|
|
1362
|
+
const already = deps.bindings.findTurn?.(bindingId, clientTurnId) ?? null
|
|
1363
|
+
if (already !== null && already.result && typeof already.result === 'object') {
|
|
1364
|
+
const { status, ...rest } = already.result as Record<string, unknown>
|
|
1365
|
+
if (!res.headersSent) {
|
|
1366
|
+
res.status(typeof status === 'number' ? status : 200).json({ ...rest, replayed: true })
|
|
1367
|
+
}
|
|
1368
|
+
return
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// The client-queued-prompt gate, used rather than reimplemented: it is the
|
|
1372
|
+
// one place that orders state before epoch before target, and a second
|
|
1373
|
+
// opinion here is how the store's own header says the two drifted apart.
|
|
1374
|
+
const gate = deps.bindings.checkQueuedPrompt!({ bindingId, epoch: epoch as number, targetKey: claimedTargetKey }, now)
|
|
1375
|
+
if (gate?.ok !== true) return refuseTurn(registryRefusal(gate?.reason))
|
|
1376
|
+
|
|
1377
|
+
const binding = deps.bindings.get!(bindingId)
|
|
1378
|
+
// Only `active` runs work. `staging` is the pre-commit state of the journaled
|
|
1379
|
+
// Chat handoff and must never execute against a Chat that can still roll back.
|
|
1380
|
+
const usable = assertUsable(binding ?? null, now)
|
|
1381
|
+
if (usable.ok !== true || !binding) return refuseTurn(registryRefusal(usable.reason ?? 'unknown_binding'))
|
|
1382
|
+
if (binding.targetKey !== claimedTargetKey) return refuseTurn('target_mismatch')
|
|
1383
|
+
if (binding.epoch !== epoch) return refuseTurn('stale_epoch')
|
|
1384
|
+
if (typeof boundTo === 'string' && boundTo !== opaqueRevision(boundToMarker(binding))) {
|
|
1385
|
+
return refuseTurn('target_mismatch')
|
|
1386
|
+
}
|
|
1387
|
+
if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
|
|
1388
|
+
return refuseTurn('binding_unusable')
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const key = binding.targetKey
|
|
1392
|
+
const fenced = guard.fencedReason(key)
|
|
1393
|
+
if (fenced !== null) {
|
|
1394
|
+
return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. Check-and-set in one
|
|
1398
|
+
// call: with an await between them, two turns admitted in the same tick both
|
|
1399
|
+
// see a free target and both deliver.
|
|
1400
|
+
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
1401
|
+
claimedKey = key
|
|
1402
|
+
|
|
1403
|
+
// Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
|
|
1404
|
+
// session started in the gap is exactly the residual risk option B leaves
|
|
1405
|
+
// open, and it is terminal here rather than a warning because COS has no
|
|
1406
|
+
// cross-process lock that could fence a live desktop writer.
|
|
1407
|
+
const verdict = runOccupancy(binding.provider, binding.nativeThreadId)
|
|
1408
|
+
if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
|
|
1409
|
+
|
|
1410
|
+
const head = await readHead(binding.provider, binding.nativeThreadId)
|
|
1411
|
+
if (head === null) return refuseTurn('native_head_unavailable')
|
|
1412
|
+
|
|
1413
|
+
// The attach baseline, advanced by each completed turn and by each explicit
|
|
1414
|
+
// Continue Anyway. Without the advance the SECOND turn on a binding always
|
|
1415
|
+
// reads as diverged, because the first turn is what moved the head.
|
|
1416
|
+
const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
|
|
1417
|
+
if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
|
|
1418
|
+
if (head.digest !== baseline) {
|
|
1419
|
+
// Only a changed/not-changed signal and a new opaque revision. No diff, no
|
|
1420
|
+
// content, no path — the client is told THAT it moved, never to what.
|
|
1421
|
+
if (acknowledged !== head.digest) {
|
|
1422
|
+
return refuseTurn('native_thread_changed', { changed: true, revision: head.digest })
|
|
1423
|
+
}
|
|
1424
|
+
// Continue Anyway: a new admission carrying the acknowledged revision.
|
|
1425
|
+
// Recorded now rather than on completion, because the user acknowledged
|
|
1426
|
+
// this revision whatever the turn goes on to do.
|
|
1427
|
+
guard.acknowledgeHead(bindingId, head.digest)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// A failed pin is FATAL, per the registry's own caller contract: an unpinned
|
|
1431
|
+
// binding can expire or be detached mid-turn, which defeats the lease.
|
|
1432
|
+
const pinned = deps.bindings.pin!(bindingId, turnId, now)
|
|
1433
|
+
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
1434
|
+
pinnedBindingId = bindingId
|
|
1435
|
+
|
|
1436
|
+
let delivery: Delivery
|
|
1437
|
+
deliveryAttempted = true
|
|
1438
|
+
try {
|
|
1439
|
+
const result = await deliver({
|
|
1440
|
+
turnId,
|
|
1441
|
+
bindingId,
|
|
1442
|
+
epoch: binding.epoch,
|
|
1443
|
+
provider: binding.provider,
|
|
1444
|
+
nativeThreadId: binding.nativeThreadId,
|
|
1445
|
+
workspaceFingerprint: binding.workspaceFingerprint,
|
|
1446
|
+
sourceFingerprint: binding.sourceFingerprint,
|
|
1447
|
+
expectedNativeHead: head.raw,
|
|
1448
|
+
prompt,
|
|
1449
|
+
onSpawn: (pid: number): boolean => {
|
|
1450
|
+
// THE SELF-RECURSION ORDER. The child registers itself against the id
|
|
1451
|
+
// we are targeting, so unless it is in the ledger the next occupancy
|
|
1452
|
+
// check reads our own process as a live foreign owner.
|
|
1453
|
+
let startMs: number | null = null
|
|
1454
|
+
try {
|
|
1455
|
+
// MEASURED, never `Date.now()`. The wall clock drifts up to 992 ms
|
|
1456
|
+
// against a 1500 ms tolerance, and a near-miss silently disables
|
|
1457
|
+
// self-identification instead of failing loudly.
|
|
1458
|
+
startMs = deps.probes.processStartMs(pid)
|
|
1459
|
+
} catch {
|
|
1460
|
+
startMs = null
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof startMs !== 'number' || !Number.isFinite(startMs)) return false
|
|
1463
|
+
let outcome: string
|
|
1464
|
+
try {
|
|
1465
|
+
outcome = ownership.record(pid, startMs)
|
|
1466
|
+
} catch {
|
|
1467
|
+
return false
|
|
1468
|
+
}
|
|
1469
|
+
// The ledger reports WHY it refused. Anything but an accepted claim
|
|
1470
|
+
// means this child is unrecognisable to us, so the adapter must abort
|
|
1471
|
+
// before the prompt rather than deliver a turn that poisons the next
|
|
1472
|
+
// occupancy check.
|
|
1473
|
+
if (outcome !== 'recorded') return false
|
|
1474
|
+
recordedPids.push(pid)
|
|
1475
|
+
return true
|
|
1476
|
+
},
|
|
1477
|
+
})
|
|
1478
|
+
delivery = classifyDelivery(result)
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
|
|
1481
|
+
delivery = { kind: 'ambiguous' }
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
|
|
1485
|
+
|
|
1486
|
+
if (delivery.kind === 'ambiguous') {
|
|
1487
|
+
// Plan 4.6: the reservation is HELD, not released. A hand-crafted next
|
|
1488
|
+
// admission — the client Retry button that mints a fresh generation and
|
|
1489
|
+
// clears every other fence — has to hit something server-side, and this is
|
|
1490
|
+
// it. The fence outlives the binding, so re-attaching does not open it.
|
|
1491
|
+
//
|
|
1492
|
+
// Fenced under its own reason, not this turn's: `delivery_ambiguous`
|
|
1493
|
+
// describes what happened to THIS request, while a later caller needs to
|
|
1494
|
+
// be told the thread is shut and why it must be inspected first.
|
|
1495
|
+
guard.fence(key, 'native_target_fenced')
|
|
1496
|
+
return reportAmbiguous()
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
let after = delivery.after === null ? null : opaqueRevision(delivery.after)
|
|
1500
|
+
if (after === null) {
|
|
1501
|
+
// Best effort. If it fails the baseline simply does not advance and the
|
|
1502
|
+
// next turn asks for an acknowledgement — conservative, never permissive.
|
|
1503
|
+
const reread = await readHead(binding.provider, binding.nativeThreadId)
|
|
1504
|
+
after = reread === null ? null : reread.digest
|
|
1505
|
+
}
|
|
1506
|
+
if (after !== null) guard.acknowledgeHead(bindingId, after)
|
|
1507
|
+
|
|
1508
|
+
// Phase 4 owns Message persistence. Nothing about the prompt or the reply is
|
|
1509
|
+
// written, logged or echoed here; the terminal outcome is the whole result.
|
|
1510
|
+
respond(200, {
|
|
1511
|
+
turnId,
|
|
1512
|
+
outcome: 'completed',
|
|
1513
|
+
deliveryState: 'delivered',
|
|
1514
|
+
retryable: false,
|
|
1515
|
+
changed: false,
|
|
1516
|
+
revision: after,
|
|
1517
|
+
reason: null,
|
|
1518
|
+
reasonCopy: TURN_SENT_COPY,
|
|
1519
|
+
})
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
console.error(`[agent-session-bindings] turn failed: ${error instanceof Error ? error.message : error}`)
|
|
1522
|
+
if (deliveryAttempted) {
|
|
1523
|
+
// A bug in this route that happened AROUND a delivery is indistinguishable
|
|
1524
|
+
// from a delivery.
|
|
1525
|
+
if (claimedKey !== null) guard.fence(claimedKey, 'native_target_fenced')
|
|
1526
|
+
reportAmbiguous()
|
|
1527
|
+
} else {
|
|
1528
|
+
refuseTurn('turn_failed')
|
|
1529
|
+
}
|
|
1530
|
+
} finally {
|
|
1531
|
+
for (const pid of recordedPids) {
|
|
1532
|
+
try {
|
|
1533
|
+
ownership.release(pid)
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
console.error(`[agent-session-bindings] spawn release failed: ${error instanceof Error ? error.message : error}`)
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (pinnedBindingId !== null) {
|
|
1539
|
+
try {
|
|
1540
|
+
deps.bindings.unpin!(pinnedBindingId, turnId, readNow() ?? requestNow)
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
if (claimedKey !== null) guard.release(claimedKey, turnId)
|
|
1546
|
+
}
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
return router
|
|
1550
|
+
}
|