@gotcos/glasses-server 6.52.3 → 6.53.1
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 +33 -0
- package/bin/cli.cjs +4 -2
- package/bin/hooks/cos-session-hook +50 -1
- package/package.json +1 -1
- package/server/index.ts +36 -2
- package/server/lib/attached-provider-adapter.ts +338 -28
- package/server/lib/claude-hooks-installer.ts +10 -1
- package/server/lib/cursor-model-catalog.ts +45 -12
- package/server/lib/permission-broker.ts +48 -0
- package/server/lib/session-cancel.ts +190 -0
- package/server/lib/session-halt.ts +156 -0
- package/server/lib/session-hooks-runtime.ts +45 -2
- package/server/lib/thread-turn-queue.ts +9 -0
- package/server/routes/agent-session-bindings.ts +328 -6
- package/server/routes/health.ts +6 -1
- package/server/routes/thread-turn-queue.ts +16 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// GET /api/agent-sessions/bindings
|
|
3
3
|
// POST /api/agent-sessions/:provider/:threadId/attach
|
|
4
4
|
// POST /api/agent-sessions/bindings/:bindingId/turns
|
|
5
|
+
// POST /api/agent-sessions/:provider/:threadId/cancel (6.53.0, see its handler)
|
|
5
6
|
//
|
|
6
7
|
// The client-facing half of Continue Original Agent Thread. Phase 0 resolved
|
|
7
8
|
// plan 4.3 to option B: COS attaches to a native desktop thread ONLY when no
|
|
@@ -125,6 +126,16 @@ import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/ag
|
|
|
125
126
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
126
127
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
127
128
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
129
|
+
import {
|
|
130
|
+
CANCEL_QUEUE_HOLD_MS,
|
|
131
|
+
CLIENT_CANCEL_ID_RE,
|
|
132
|
+
cancelRefusalCopy,
|
|
133
|
+
cancelTargetFor,
|
|
134
|
+
type CancelFacts,
|
|
135
|
+
type CancelRefusal,
|
|
136
|
+
type CancelTarget,
|
|
137
|
+
type SessionCancelLedgerRow,
|
|
138
|
+
} from '../lib/session-cancel.js'
|
|
128
139
|
|
|
129
140
|
/**
|
|
130
141
|
* Read side of the binding lease store.
|
|
@@ -247,6 +258,12 @@ export interface AttachedTurnRequest {
|
|
|
247
258
|
* simply never calls it.
|
|
248
259
|
*/
|
|
249
260
|
onSpawn: (pid: number) => boolean
|
|
261
|
+
/**
|
|
262
|
+
* 6.53.0: aborted by POST .../cancel. The adapter checks it before the spawn and
|
|
263
|
+
* before the prompt write, and stops the child's process group after. Optional so an
|
|
264
|
+
* adapter that ignores it is byte-for-byte the 6.52 route.
|
|
265
|
+
*/
|
|
266
|
+
abortSignal?: AbortSignal
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
/**
|
|
@@ -271,7 +288,7 @@ export type AttachedTurnResult =
|
|
|
271
288
|
* that module's own `attachedDeliveryAmbiguous`: only `not_attempted` and
|
|
272
289
|
* `aborted` are proof that nothing was sent.
|
|
273
290
|
*/
|
|
274
|
-
| { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' }
|
|
291
|
+
| { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled' }
|
|
275
292
|
|
|
276
293
|
export interface AgentSessionBindingsDeps {
|
|
277
294
|
/** Shared fence state. Omit and the router owns a private one, which is correct
|
|
@@ -407,6 +424,44 @@ export interface AgentSessionBindingsDeps {
|
|
|
407
424
|
maxPromptChars?: number
|
|
408
425
|
/** Injected so a test can pin the minted ids. Must return an id matching BINDING_ID_RE. */
|
|
409
426
|
newId?: () => string
|
|
427
|
+
|
|
428
|
+
// ------------------------------------------------------------- cancel side
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 6.53.0: what the cancel route and the attachability `cancel` field read about runs
|
|
432
|
+
* OUTSIDE this router. Every member is optional and an absent one reads as the cautious
|
|
433
|
+
* fact: no desk run seen, hooks off, not ready, nothing held. So an older wiring answers
|
|
434
|
+
* `not_running` or `hooks_disabled`, never a cancel it cannot carry out. A COS turn in
|
|
435
|
+
* flight is this router's own knowledge and needs none of it.
|
|
436
|
+
*/
|
|
437
|
+
cancel?: CancelDeps
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface CancelDeps {
|
|
441
|
+
/** A Claude session is running at the desk: its hooks say the turn is open, or its registry record says busy. */
|
|
442
|
+
deskRunning?: (sessionId: string) => boolean
|
|
443
|
+
/** `sessionHooksEnabled()`. */
|
|
444
|
+
hooksEnabled?: () => boolean
|
|
445
|
+
/** The installed hooks carry the 6.53.0 script and PreToolUse subscription. */
|
|
446
|
+
hooksReady?: () => boolean
|
|
447
|
+
/** `writeHaltMarker`. False means the cancel could not be armed. */
|
|
448
|
+
writeHalt?: (sessionId: string, marker: { at: number; clientCancelId: string }) => boolean
|
|
449
|
+
/** Settle this session's held permission prompts as deny with interrupt; returns how many. */
|
|
450
|
+
settlePermissions?: (sessionId: string) => number
|
|
451
|
+
/** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
|
|
452
|
+
queuedWaiting?: (provider: string, threadId: string) => number
|
|
453
|
+
/** Record the cancel time the turn queue reads (`noteThreadCancelled`). */
|
|
454
|
+
noteCancelled?: (provider: string, threadId: string, at: number) => void
|
|
455
|
+
/** Append to `data/session-cancel.jsonl`. */
|
|
456
|
+
ledger?: (row: SessionCancelLedgerRow) => void
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The attachability body plus what a cancel would do (6.53.0). A separate type so every
|
|
461
|
+
* other caller of `projectAttachability` keeps its four fields.
|
|
462
|
+
*/
|
|
463
|
+
export interface AttachabilityWithCancel extends AttachabilityBody {
|
|
464
|
+
cancel: CancelTarget | null
|
|
410
465
|
}
|
|
411
466
|
|
|
412
467
|
/**
|
|
@@ -513,6 +568,10 @@ export type WriteRefusal =
|
|
|
513
568
|
// thread dies on "already has an active writer" after the prompt is written, which
|
|
514
569
|
// is the ambiguous fence this whole branch exists to avoid.
|
|
515
570
|
| 'codex_live_unavailable'
|
|
571
|
+
// 6.53.0: the turn was stopped by POST .../cancel. Terminal and never retried: the
|
|
572
|
+
// person said stop. Deliberately NOT a fence (plan validation B2): the next Continue
|
|
573
|
+
// must work, and it re-reads the head.
|
|
574
|
+
| 'turn_cancelled'
|
|
516
575
|
// ------------------------------------------------------------------- fork
|
|
517
576
|
//
|
|
518
577
|
// Fork gets its OWN members rather than reusing the ones above, and the reason
|
|
@@ -591,6 +650,8 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
591
650
|
'The Codex app has this thread open, and COS could not hand it the message. Nothing was sent. Send it from the Codex app, or fork it.',
|
|
592
651
|
turn_failed:
|
|
593
652
|
'COS could not run this turn. Nothing was sent. You can try again.',
|
|
653
|
+
turn_cancelled:
|
|
654
|
+
'Run cancelled.',
|
|
594
655
|
|
|
595
656
|
// Fork copy. No sentence here may end with "Fork it instead" — this IS the fork,
|
|
596
657
|
// and pointing a failed fork back at itself is a dead end rather than an action.
|
|
@@ -1120,10 +1181,23 @@ export class TargetGuard {
|
|
|
1120
1181
|
}
|
|
1121
1182
|
}
|
|
1122
1183
|
|
|
1184
|
+
/** A COS turn this router is running (6.53.0). */
|
|
1185
|
+
interface InFlightTurn {
|
|
1186
|
+
turnId: string
|
|
1187
|
+
bindingId: string
|
|
1188
|
+
clientTurnId: string | null
|
|
1189
|
+
controller: AbortController
|
|
1190
|
+
startedAt: number
|
|
1191
|
+
/** False while a live hand-off is crossing its irreversible write boundary. */
|
|
1192
|
+
cancellable: boolean
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1123
1195
|
type Delivery =
|
|
1124
1196
|
| { kind: 'completed'; after: string | null }
|
|
1125
1197
|
| { kind: 'aborted' }
|
|
1126
1198
|
| { kind: 'ambiguous' }
|
|
1199
|
+
/** Stopped by a cancel this route requested and positively reaped. */
|
|
1200
|
+
| { kind: 'cancelled' }
|
|
1127
1201
|
|
|
1128
1202
|
/**
|
|
1129
1203
|
* Read an adapter result without believing anything it did not say.
|
|
@@ -1131,9 +1205,27 @@ type Delivery =
|
|
|
1131
1205
|
* The default is ambiguous, and every unrecognised shape lands there: null, an
|
|
1132
1206
|
* array, a missing status, a status from a newer adapter. Only the two literals
|
|
1133
1207
|
* this build understands are allowed to mean anything.
|
|
1208
|
+
*
|
|
1209
|
+
* `cancelRequested` (6.53.0) is THIS ROUTE's own knowledge that it aborted the turn. The
|
|
1210
|
+
* adapter's `cancelled` is believed only then: a result claiming a cancel nobody asked for
|
|
1211
|
+
* is a contradiction, and a contradiction is ambiguous, which fences. The other way round
|
|
1212
|
+
* holds too: a cancel we asked for that the adapter reports as delivered was delivered.
|
|
1134
1213
|
*/
|
|
1135
|
-
export function classifyDelivery(result: unknown): Delivery {
|
|
1214
|
+
export function classifyDelivery(result: unknown, cancelRequested = false): Delivery {
|
|
1136
1215
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'ambiguous' }
|
|
1216
|
+
if (cancelRequested === true) {
|
|
1217
|
+
const { ok, reason, delivery, reaped } = result as { ok?: unknown; reason?: unknown; delivery?: unknown; reaped?: unknown }
|
|
1218
|
+
if (ok === false && reason === 'cancelled'
|
|
1219
|
+
&& ((delivery === 'cancelled' && reaped === true)
|
|
1220
|
+
|| delivery === 'not_attempted'
|
|
1221
|
+
|| (delivery === 'aborted' && reaped === true))) {
|
|
1222
|
+
return { kind: 'cancelled' }
|
|
1223
|
+
}
|
|
1224
|
+
// A cancel result that names a spawned-but-unreaped child must not fall
|
|
1225
|
+
// through to the generic `aborted` classifier below. The route requested
|
|
1226
|
+
// the stop, but it cannot claim the process tree is gone, so fence it.
|
|
1227
|
+
if (ok === false && reason === 'cancelled') return { kind: 'ambiguous' }
|
|
1228
|
+
}
|
|
1137
1229
|
const status = (result as { status?: unknown }).status
|
|
1138
1230
|
if (status === 'completed') {
|
|
1139
1231
|
const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
|
|
@@ -1402,6 +1494,61 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1402
1494
|
: DEFAULT_MAX_PROMPT_CHARS
|
|
1403
1495
|
const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
|
|
1404
1496
|
|
|
1497
|
+
// ------------------------------------------------------------ in flight (6.53.0)
|
|
1498
|
+
//
|
|
1499
|
+
// Every COS turn this router is running, by target, from the moment its claim is taken
|
|
1500
|
+
// until the `finally` that releases the claim. It sits next to `TargetGuard.claims` and
|
|
1501
|
+
// has the same lifetime, so "a COS turn is in flight" here and "the target is claimed"
|
|
1502
|
+
// there cannot disagree. The controller is the ONLY way a cancel reaches the child:
|
|
1503
|
+
// never a stored pid, which could have been recycled by the time anyone signals it.
|
|
1504
|
+
// Entries are deleted by turnId, so a late unwind cannot delete its successor's entry.
|
|
1505
|
+
const inFlight = new Map<string, InFlightTurn>()
|
|
1506
|
+
const cancelDeps: CancelDeps = deps?.cancel ?? {}
|
|
1507
|
+
/** A throwing or absent probe reads as the cautious answer, never as a thrown route. */
|
|
1508
|
+
const cancelProbe = <T>(read: (() => T) | undefined, fallback: T): T => {
|
|
1509
|
+
if (typeof read !== 'function') return fallback
|
|
1510
|
+
try {
|
|
1511
|
+
return read()
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
console.error(`[agent-session-bindings] cancel probe threw: ${error instanceof Error ? error.message : error}`)
|
|
1514
|
+
return fallback
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* The facts `cancelTargetFor` decides on, for one thread. `verdictReason` is the
|
|
1519
|
+
* occupancy reason when the caller already holds a verdict (attachability) or null.
|
|
1520
|
+
* For Claude, a wired `deskRunning` is the authority (the hooks and the registry know a
|
|
1521
|
+
* desk turn precisely; a transcript written 20 s ago does not); without it, and for
|
|
1522
|
+
* Codex and Cursor, "a foreign writer is working" is the occupancy verdict's own
|
|
1523
|
+
* `native_thread_working`.
|
|
1524
|
+
*/
|
|
1525
|
+
const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
|
|
1526
|
+
const entry = inFlight.get(targetKey(provider, threadId))
|
|
1527
|
+
const cosTurnInFlight = entry?.cancellable === true
|
|
1528
|
+
let runningOutsideCos = false
|
|
1529
|
+
// A live hand-off that has started is still ours, but cannot honestly be
|
|
1530
|
+
// cancelled: do not misclassify the same run as a desk run while it commits.
|
|
1531
|
+
if (!entry) {
|
|
1532
|
+
runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
|
|
1533
|
+
? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
|
|
1534
|
+
: verdictReason === 'native_thread_working'
|
|
1535
|
+
}
|
|
1536
|
+
return {
|
|
1537
|
+
provider,
|
|
1538
|
+
cosTurnInFlight,
|
|
1539
|
+
runningOutsideCos,
|
|
1540
|
+
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
1541
|
+
hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
/** The attachability body's `cancel`, for a thread id the caller has NOT validated. */
|
|
1545
|
+
const cancelFieldFor = (provider: string, threadId: string, verdictReason: string | null): CancelTarget | null => {
|
|
1546
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return null
|
|
1547
|
+
return cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
|
|
1548
|
+
}
|
|
1549
|
+
/** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
|
|
1550
|
+
const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
|
|
1551
|
+
|
|
1405
1552
|
/**
|
|
1406
1553
|
* One owner for "is this thread free right now", shared by the probe and both
|
|
1407
1554
|
* writes, including its own try/catch and the self-contradiction re-check.
|
|
@@ -1535,15 +1682,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1535
1682
|
// fail. It also keeps the surface self-consistent: reporting `attachable: true`
|
|
1536
1683
|
// while the attach route is unrouted would leave a client unable to tell
|
|
1537
1684
|
// whether the thread is free or the feature is off.
|
|
1685
|
+
// 6.53.0: every answer below also says what a cancel would do (`cancelTargetFor`, the
|
|
1686
|
+
// same function the cancel route decides with). Null means no run to cancel, and the
|
|
1687
|
+
// lens then shows no row. Computed on the disabled paths too: a desk run can be
|
|
1688
|
+
// cancelled whether or not COS may write into the thread.
|
|
1689
|
+
const provider = String(req.params.provider ?? '')
|
|
1690
|
+
const threadId = String(req.params.threadId ?? '')
|
|
1691
|
+
const withCancel = (body: AttachabilityBody): AttachabilityWithCancel =>
|
|
1692
|
+
({ ...body, cancel: cancelFieldFor(provider, threadId, body.reason) })
|
|
1693
|
+
|
|
1538
1694
|
if (!attachEnabled) {
|
|
1539
|
-
res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
|
|
1695
|
+
res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' })))
|
|
1540
1696
|
return
|
|
1541
1697
|
}
|
|
1542
1698
|
|
|
1543
1699
|
if (!canDetect) {
|
|
1544
1700
|
// The mechanism does not exist on this install. Distinct from "it ran and
|
|
1545
1701
|
// found nothing" by design (plan 4.3 wants the reason nameable).
|
|
1546
|
-
res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
|
|
1702
|
+
res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' })))
|
|
1547
1703
|
return
|
|
1548
1704
|
}
|
|
1549
1705
|
|
|
@@ -1561,7 +1717,133 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1561
1717
|
// truncated-id hole opened. The tests pin the ordering behaviorally instead:
|
|
1562
1718
|
// probes that throw on every call still return `unsupported_provider` /
|
|
1563
1719
|
// `invalid_thread_id`, which is only possible if nothing was probed.
|
|
1564
|
-
res.json(runOccupancy(
|
|
1720
|
+
res.json(withCancel(runOccupancy(provider, threadId)))
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
// ------------------------------------------------------------------ cancel
|
|
1724
|
+
//
|
|
1725
|
+
// POST /api/agent-sessions/:provider/:threadId/cancel { clientCancelId }
|
|
1726
|
+
//
|
|
1727
|
+
// 6.53.0. Miles, 2026-09-21: "If there's a current run that's going, it essentially
|
|
1728
|
+
// clicks the cancel button ... similar to when they're at the desktop."
|
|
1729
|
+
//
|
|
1730
|
+
// REGISTERED UNCONDITIONALLY, not behind `attachEnabled`: a desk Claude run can be
|
|
1731
|
+
// stopped whether or not COS may write into the thread, and a 404 here is how the app
|
|
1732
|
+
// recognises a server older than 6.53 ("Update the server to cancel runs").
|
|
1733
|
+
//
|
|
1734
|
+
// WHAT IT CAN DO is decided by `cancelTargetFor`, the function the attachability
|
|
1735
|
+
// `cancel` field uses, so the row the lens showed and the answer to the tap agree:
|
|
1736
|
+
// cos_turn 202 {target, state: 'stopping', turnId, queuedHeld, queuedHoldMs}: our own child's
|
|
1737
|
+
// controller is aborted, and the turn route records `turn_cancelled`.
|
|
1738
|
+
// desk_run 202 {target, effective: 'next_tool_call', queuedHeld, queuedHoldMs}: a halt marker
|
|
1739
|
+
// the hook reads before the session's next tool call, and any permission
|
|
1740
|
+
// prompt the broker holds for that session is denied with interrupt.
|
|
1741
|
+
// refusals 409 {reason, reasonCopy}: not_running, cancel_unsupported (Codex app,
|
|
1742
|
+
// Cursor), hooks_outdated (Install hooks), hooks_disabled.
|
|
1743
|
+
//
|
|
1744
|
+
// `queuedHoldMs` is how long the parked turns wait (CANCEL_QUEUE_HOLD_MS), so a client
|
|
1745
|
+
// never hard-codes the two minutes.
|
|
1746
|
+
//
|
|
1747
|
+
// IDEMPOTENT PER clientCancelId: the same tap retried gets the same status and body
|
|
1748
|
+
// (plus `replayed: true`), and does not arm or abort anything twice.
|
|
1749
|
+
//
|
|
1750
|
+
// The body carries no path, no pid and no native id beyond the one in the URL.
|
|
1751
|
+
const CANCEL_REPLY_TTL_MS = 10 * 60_000
|
|
1752
|
+
const MAX_CANCEL_REPLIES = 256
|
|
1753
|
+
router.post('/agent-sessions/:provider/:threadId/cancel', (req, res) => {
|
|
1754
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1755
|
+
const provider = String(req.params.provider ?? '')
|
|
1756
|
+
// Lower-cased BEFORE validation: it names a file and a map key, and the hook
|
|
1757
|
+
// lower-cases the id it reads, so any other spelling could never match.
|
|
1758
|
+
const threadId = String(req.params.threadId ?? '').toLowerCase()
|
|
1759
|
+
const body = plainBody(req)
|
|
1760
|
+
const clientCancelId = body?.clientCancelId
|
|
1761
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)
|
|
1762
|
+
|| typeof clientCancelId !== 'string' || !CLIENT_CANCEL_ID_RE.test(clientCancelId)) {
|
|
1763
|
+
res.status(400).json({ cancelled: false, reason: 'invalid_request', reasonCopy: cancelRefusalCopy('invalid_request', provider) })
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const now = readNow() ?? Date.now()
|
|
1768
|
+
for (const [k, v] of cancelReplies) if (now - v.at > CANCEL_REPLY_TTL_MS) cancelReplies.delete(k)
|
|
1769
|
+
const replayKey = `${provider}:${threadId}:${clientCancelId}`
|
|
1770
|
+
const replay = cancelReplies.get(replayKey)
|
|
1771
|
+
if (replay) {
|
|
1772
|
+
res.status(replay.status).json({ ...replay.body, replayed: true })
|
|
1773
|
+
return
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const answer = (status: number, target: CancelTarget | null, outcome: 'accepted' | CancelRefusal, payload: Record<string, unknown>): void => {
|
|
1777
|
+
cancelReplies.set(replayKey, { status, body: payload, at: now })
|
|
1778
|
+
while (cancelReplies.size > MAX_CANCEL_REPLIES) {
|
|
1779
|
+
const oldest = cancelReplies.keys().next()
|
|
1780
|
+
if (oldest.done) break
|
|
1781
|
+
cancelReplies.delete(oldest.value)
|
|
1782
|
+
}
|
|
1783
|
+
cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId }), undefined)
|
|
1784
|
+
console.log(`[agent-session-bindings] cancel provider=${provider} target=${target ?? 'none'} outcome=${outcome} status=${status} clientCancelId=${clientCancelId}`)
|
|
1785
|
+
res.status(status).json(payload)
|
|
1786
|
+
}
|
|
1787
|
+
const refuseCancel = (target: CancelTarget | null, reason: CancelRefusal, status = 409): void =>
|
|
1788
|
+
answer(status, target, reason, {
|
|
1789
|
+
cancelled: false,
|
|
1790
|
+
reason,
|
|
1791
|
+
...(reason === 'cancel_unsupported' ? { provider } : {}),
|
|
1792
|
+
reasonCopy: cancelRefusalCopy(reason, provider),
|
|
1793
|
+
})
|
|
1794
|
+
|
|
1795
|
+
// The occupancy verdict is read only when the decision needs it: never for a COS turn
|
|
1796
|
+
// (the map knows) and never for Claude with a desk probe wired (the hooks know).
|
|
1797
|
+
const key = targetKey(provider, threadId)
|
|
1798
|
+
const activeEntry = inFlight.get(key)
|
|
1799
|
+
if (activeEntry && activeEntry.cancellable !== true) {
|
|
1800
|
+
return refuseCancel(null, 'cancel_failed')
|
|
1801
|
+
}
|
|
1802
|
+
const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
|
|
1803
|
+
const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
|
|
1804
|
+
const target = cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
|
|
1805
|
+
const queuedHeld = (): number => {
|
|
1806
|
+
const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
|
|
1807
|
+
return Number.isInteger(n) && n > 0 ? n : 0
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if (target === 'cos_turn') {
|
|
1811
|
+
const entry = inFlight.get(key)
|
|
1812
|
+
if (!entry) return refuseCancel(null, 'not_running')
|
|
1813
|
+
// The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
|
|
1814
|
+
// route records the outcome; this answer does not wait for either.
|
|
1815
|
+
entry.controller.abort()
|
|
1816
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
1817
|
+
return answer(202, target, 'accepted', {
|
|
1818
|
+
cancelled: true,
|
|
1819
|
+
target,
|
|
1820
|
+
state: 'stopping',
|
|
1821
|
+
turnId: entry.turnId,
|
|
1822
|
+
queuedHeld: queuedHeld(),
|
|
1823
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
1824
|
+
})
|
|
1825
|
+
}
|
|
1826
|
+
if (target === 'desk_run') {
|
|
1827
|
+
const armed = cancelProbe(() => cancelDeps.writeHalt?.(threadId, { at: now, clientCancelId }) === true, false)
|
|
1828
|
+
// Not armed means nothing will stop the run: never report it accepted.
|
|
1829
|
+
if (!armed) return refuseCancel(target, 'cancel_failed', 500)
|
|
1830
|
+
// The away-from-desk case: a prompt the broker is holding would otherwise keep the
|
|
1831
|
+
// run waiting for its answer, and then show the Mac's own dialog. Deny it, with
|
|
1832
|
+
// interrupt, so the run ends now rather than at a tool that never comes.
|
|
1833
|
+
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1834
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
1835
|
+
return answer(202, target, 'accepted', {
|
|
1836
|
+
cancelled: true,
|
|
1837
|
+
target,
|
|
1838
|
+
effective: 'next_tool_call',
|
|
1839
|
+
queuedHeld: queuedHeld(),
|
|
1840
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
1841
|
+
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1842
|
+
})
|
|
1843
|
+
}
|
|
1844
|
+
if (target === 'unsupported') return refuseCancel(target, 'cancel_unsupported')
|
|
1845
|
+
if (target === 'hooks_outdated' || target === 'hooks_disabled') return refuseCancel(target, target)
|
|
1846
|
+
return refuseCancel(null, 'not_running')
|
|
1565
1847
|
})
|
|
1566
1848
|
|
|
1567
1849
|
router.get('/agent-sessions/bindings', (_req, res) => {
|
|
@@ -1952,6 +2234,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1952
2234
|
`[agent-session-bindings] turn timing turnId=${turnId} outcome=${outcome} total=${Number((process.hrtime.bigint() - stageClock) / 1_000_000n)}ms ${stages.join(' ')}`
|
|
1953
2235
|
/** The target we hold a claim on, released in the finally. */
|
|
1954
2236
|
let claimedKey: string | null = null
|
|
2237
|
+
/** 6.53.0: this turn's cancel. Registered in `inFlight` at the claim, deleted in the finally. */
|
|
2238
|
+
const cancelController = new AbortController()
|
|
1955
2239
|
// Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
|
|
1956
2240
|
// both declared inside the try, so neither is in scope where the route-error
|
|
1957
2241
|
// fence is set — without these it would store a fence it can say nothing about.
|
|
@@ -2167,6 +2451,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2167
2451
|
// see a free target and both deliver.
|
|
2168
2452
|
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
2169
2453
|
claimedKey = key
|
|
2454
|
+
// 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
|
|
2455
|
+
inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true })
|
|
2170
2456
|
|
|
2171
2457
|
// Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
|
|
2172
2458
|
// session started in the gap is exactly the residual risk option B leaves
|
|
@@ -2212,6 +2498,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2212
2498
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
2213
2499
|
pinnedBindingId = bindingId
|
|
2214
2500
|
|
|
2501
|
+
// 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
|
|
2502
|
+
// which cannot be recalled once it writes into the open session. Nothing was sent;
|
|
2503
|
+
// not retryable, because the person asked for this turn to stop.
|
|
2504
|
+
if (cancelController.signal.aborted) {
|
|
2505
|
+
console.log(`[agent-session-bindings] turn cancelled before delivery provider=${binding.provider} turnId=${turnId} bindingId=${bindingId}`)
|
|
2506
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2215
2509
|
// 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
|
|
2216
2510
|
// a `claude` in iTerm, the same registry record either way -- the turn goes into
|
|
2217
2511
|
// it and paints where the user left the window, instead of into a resume child
|
|
@@ -2225,6 +2519,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2225
2519
|
// on its own clock; the next interactive turn reads that as a change and asks,
|
|
2226
2520
|
// which is the conservative side. Queued turns attach fresh and never notice.
|
|
2227
2521
|
if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
|
|
2522
|
+
const liveEntry = inFlight.get(key)
|
|
2523
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2228
2524
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
|
|
2229
2525
|
try {
|
|
2230
2526
|
live = await deps.deliverLiveTurn({
|
|
@@ -2260,6 +2556,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2260
2556
|
// Something may be in the session. Hold, never spawn over it.
|
|
2261
2557
|
return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
|
|
2262
2558
|
}
|
|
2559
|
+
// Every remaining result is documented as "nothing reached a session";
|
|
2560
|
+
// the abortable child path is safe to expose again before it starts.
|
|
2561
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2263
2562
|
// disabled / not_claude / no_record / protocol_unsupported / connect_failed /
|
|
2264
2563
|
// suspect_expired: nothing reached a session. Spawn as always.
|
|
2265
2564
|
}
|
|
@@ -2272,6 +2571,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2272
2571
|
// current turn, or within seconds if the thread is idle (canary in
|
|
2273
2572
|
// `codex-live-queue.ts`). A thread nobody holds never gets here.
|
|
2274
2573
|
if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
|
|
2574
|
+
const liveEntry = inFlight.get(key)
|
|
2575
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2275
2576
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
|
|
2276
2577
|
try {
|
|
2277
2578
|
live = await deps.deliverCodexLiveTurn({
|
|
@@ -2300,6 +2601,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2300
2601
|
})
|
|
2301
2602
|
return
|
|
2302
2603
|
}
|
|
2604
|
+
// The operator switched this off: nothing was queued, so the abortable
|
|
2605
|
+
// 6.50 child path is cancellable again before it starts.
|
|
2606
|
+
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2303
2607
|
// The operator switched this off: the 6.50 path, unchanged, whatever it does.
|
|
2304
2608
|
if (live?.reason !== 'disabled') {
|
|
2305
2609
|
// A throw, a timeout, or a confirmation we could not read: a row MAY be in the
|
|
@@ -2370,6 +2674,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2370
2674
|
sourceFingerprint: binding.sourceFingerprint,
|
|
2371
2675
|
expectedNativeHead: head.raw,
|
|
2372
2676
|
prompt,
|
|
2677
|
+
abortSignal: cancelController.signal,
|
|
2373
2678
|
onSpawn: (pid: number): boolean => {
|
|
2374
2679
|
// THE SELF-RECURSION ORDER. The child registers itself against the id
|
|
2375
2680
|
// we are targeting, so unless it is in the ledger the next occupancy
|
|
@@ -2424,12 +2729,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2424
2729
|
stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
|
|
2425
2730
|
durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
|
|
2426
2731
|
}
|
|
2427
|
-
delivery = classifyDelivery(result)
|
|
2732
|
+
delivery = classifyDelivery(result, cancelController.signal.aborted)
|
|
2428
2733
|
} catch (error) {
|
|
2429
2734
|
console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
|
|
2430
2735
|
delivery = { kind: 'ambiguous' }
|
|
2431
2736
|
}
|
|
2432
2737
|
|
|
2738
|
+
// 6.53.0: CANCELLED, BEFORE THE FENCE. An aborted child exits on a signal, which
|
|
2739
|
+
// without this branch is `ambiguous` and fences the thread (plan validation B2):
|
|
2740
|
+
// the person who pressed Cancel would find the thread they stopped locked until
|
|
2741
|
+
// someone releases a fence. A cancel is not an unknown. The turn is ledgered as
|
|
2742
|
+
// refused, `turn_cancelled`, not retryable, which every app since 6.9.4xx settles.
|
|
2743
|
+
if (delivery.kind === 'cancelled') {
|
|
2744
|
+
// The head moved if the child wrote anything before the stop. Acknowledge it the
|
|
2745
|
+
// way a completed turn does, so the next Continue on this binding is not refused
|
|
2746
|
+
// `native_thread_changed` for the run the person just cancelled.
|
|
2747
|
+
const reread = await readHead(binding.provider, binding.nativeThreadId)
|
|
2748
|
+
if (reread !== null) guard.acknowledgeHead(bindingId, reread.digest)
|
|
2749
|
+
console.log(`[agent-session-bindings] turn cancelled provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} detail=${adapterEvidence.adapterDetail ?? 'none'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
|
|
2750
|
+
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2433
2753
|
if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
|
|
2434
2754
|
|
|
2435
2755
|
if (delivery.kind === 'ambiguous') {
|
|
@@ -2536,6 +2856,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2536
2856
|
console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
|
|
2537
2857
|
}
|
|
2538
2858
|
}
|
|
2859
|
+
// By turnId: only this turn's own entry, never a successor's.
|
|
2860
|
+
if (claimedKey !== null && inFlight.get(claimedKey)?.turnId === turnId) inFlight.delete(claimedKey)
|
|
2539
2861
|
if (claimedKey !== null) guard.release(claimedKey, turnId)
|
|
2540
2862
|
}
|
|
2541
2863
|
})
|
package/server/routes/health.ts
CHANGED
|
@@ -80,7 +80,8 @@ import {
|
|
|
80
80
|
threadAttachCapability,
|
|
81
81
|
threadAttachHealthFields,
|
|
82
82
|
} from '../lib/thread-attach-capability.js'
|
|
83
|
-
import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
83
|
+
import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
84
|
+
import { sessionCancelFeature } from '../lib/session-cancel.js'
|
|
84
85
|
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
85
86
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
86
87
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
@@ -293,6 +294,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
293
294
|
// This is a pure env read, so it costs nothing on a health poll, and health is
|
|
294
295
|
// what every other toggle on that screen already reads.
|
|
295
296
|
claudeSessions: claudeSessionsEnabled(),
|
|
297
|
+
// 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
|
|
298
|
+
// applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
|
|
299
|
+
// The status read is the same cached one `sessionHooks` below reports.
|
|
300
|
+
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), cachedHookStatus().installed),
|
|
296
301
|
}
|
|
297
302
|
const voice = {
|
|
298
303
|
available: keyStatus.hasKey || tts_local.ready,
|
|
@@ -32,6 +32,8 @@ export interface ThreadTurnQueueDeps {
|
|
|
32
32
|
turnOpen?: (provider: string, threadId: string) => boolean
|
|
33
33
|
/** The 30s transcript clock, as a backstop. */
|
|
34
34
|
activity: (provider: string, threadId: string) => 'working' | 'idle' | 'unknown'
|
|
35
|
+
/** 6.53.0, optional: until when a cancel holds this thread's parked turns (`cancelHoldUntil`). */
|
|
36
|
+
cancelHoldUntil?: (provider: string, threadId: string) => number | null
|
|
35
37
|
/**
|
|
36
38
|
* Deliver one turn. Production wires this to a loopback attach + turn.
|
|
37
39
|
*
|
|
@@ -85,6 +87,11 @@ function safeTurnOpen(deps: ThreadTurnQueueDeps, provider: string, threadId: str
|
|
|
85
87
|
try { return deps.turnOpen!(provider, threadId) } catch { return undefined }
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/** A throwing cancel read is no hold: the gate still decides every delivery. */
|
|
91
|
+
function safeCancelHold(deps: ThreadTurnQueueDeps, provider: string, threadId: string): number | null {
|
|
92
|
+
try { return deps.cancelHoldUntil ? deps.cancelHoldUntil(provider, threadId) : null } catch { return null }
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boolean }): boolean {
|
|
89
96
|
// The turn route publishes its own verdict; it outranks our inference either way.
|
|
90
97
|
if (outcome.serverRetryable === false) return false
|
|
@@ -134,6 +141,7 @@ export async function drainThread(
|
|
|
134
141
|
turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
|
|
135
142
|
activity: deps.activity(provider, threadId),
|
|
136
143
|
reason: gate.reason,
|
|
144
|
+
cancelHoldUntil: safeCancelHold(deps, provider, threadId),
|
|
137
145
|
}
|
|
138
146
|
const decision = drainDecision(turn, seen, deps.now())
|
|
139
147
|
|
|
@@ -167,6 +175,14 @@ export async function drainThread(
|
|
|
167
175
|
turn.status = 'delivered'
|
|
168
176
|
turn.settledAt = deps.now()
|
|
169
177
|
delivered += 1
|
|
178
|
+
} else if (outcome.reason === 'turn_cancelled') {
|
|
179
|
+
// 6.53.0: the person cancelled THIS turn while it was being admitted (the turn route
|
|
180
|
+
// refuses it before the live hop). It is the run they stopped, so it is settled as
|
|
181
|
+
// cancelled here, never put back to wait and sent again after the hold.
|
|
182
|
+
turn.status = 'cancelled'
|
|
183
|
+
turn.reason = 'turn_cancelled'
|
|
184
|
+
turn.settledAt = deps.now()
|
|
185
|
+
retired += 1
|
|
170
186
|
} else if (isRetryableDelivery(outcome)) {
|
|
171
187
|
// A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
|
|
172
188
|
// measured cost of getting this wrong: three of the user's turns retired in about
|