@gotcos/glasses-server 6.52.3 → 6.53.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 +23 -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 +91 -7
- 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 +322 -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
|
+
/** 6.53.0: stopped by a cancel this route requested. Never fenced. */
|
|
1200
|
+
| { kind: 'cancelled' }
|
|
1127
1201
|
|
|
1128
1202
|
/**
|
|
1129
1203
|
* Read an adapter result without believing anything it did not say.
|
|
@@ -1131,9 +1205,21 @@ 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 } = result as { ok?: unknown; reason?: unknown; delivery?: unknown }
|
|
1218
|
+
if (ok === false && reason === 'cancelled'
|
|
1219
|
+
&& (delivery === 'cancelled' || delivery === 'not_attempted' || delivery === 'aborted')) {
|
|
1220
|
+
return { kind: 'cancelled' }
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1137
1223
|
const status = (result as { status?: unknown }).status
|
|
1138
1224
|
if (status === 'completed') {
|
|
1139
1225
|
const after = (result as { nativeRevisionAfter?: unknown }).nativeRevisionAfter
|
|
@@ -1402,6 +1488,61 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1402
1488
|
: DEFAULT_MAX_PROMPT_CHARS
|
|
1403
1489
|
const mintId = typeof deps?.newId === 'function' ? deps.newId : () => randomUUID()
|
|
1404
1490
|
|
|
1491
|
+
// ------------------------------------------------------------ in flight (6.53.0)
|
|
1492
|
+
//
|
|
1493
|
+
// Every COS turn this router is running, by target, from the moment its claim is taken
|
|
1494
|
+
// until the `finally` that releases the claim. It sits next to `TargetGuard.claims` and
|
|
1495
|
+
// has the same lifetime, so "a COS turn is in flight" here and "the target is claimed"
|
|
1496
|
+
// there cannot disagree. The controller is the ONLY way a cancel reaches the child:
|
|
1497
|
+
// never a stored pid, which could have been recycled by the time anyone signals it.
|
|
1498
|
+
// Entries are deleted by turnId, so a late unwind cannot delete its successor's entry.
|
|
1499
|
+
const inFlight = new Map<string, InFlightTurn>()
|
|
1500
|
+
const cancelDeps: CancelDeps = deps?.cancel ?? {}
|
|
1501
|
+
/** A throwing or absent probe reads as the cautious answer, never as a thrown route. */
|
|
1502
|
+
const cancelProbe = <T>(read: (() => T) | undefined, fallback: T): T => {
|
|
1503
|
+
if (typeof read !== 'function') return fallback
|
|
1504
|
+
try {
|
|
1505
|
+
return read()
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
console.error(`[agent-session-bindings] cancel probe threw: ${error instanceof Error ? error.message : error}`)
|
|
1508
|
+
return fallback
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* The facts `cancelTargetFor` decides on, for one thread. `verdictReason` is the
|
|
1513
|
+
* occupancy reason when the caller already holds a verdict (attachability) or null.
|
|
1514
|
+
* For Claude, a wired `deskRunning` is the authority (the hooks and the registry know a
|
|
1515
|
+
* desk turn precisely; a transcript written 20 s ago does not); without it, and for
|
|
1516
|
+
* Codex and Cursor, "a foreign writer is working" is the occupancy verdict's own
|
|
1517
|
+
* `native_thread_working`.
|
|
1518
|
+
*/
|
|
1519
|
+
const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
|
|
1520
|
+
const entry = inFlight.get(targetKey(provider, threadId))
|
|
1521
|
+
const cosTurnInFlight = entry?.cancellable === true
|
|
1522
|
+
let runningOutsideCos = false
|
|
1523
|
+
// A live hand-off that has started is still ours, but cannot honestly be
|
|
1524
|
+
// cancelled: do not misclassify the same run as a desk run while it commits.
|
|
1525
|
+
if (!entry) {
|
|
1526
|
+
runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
|
|
1527
|
+
? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
|
|
1528
|
+
: verdictReason === 'native_thread_working'
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
provider,
|
|
1532
|
+
cosTurnInFlight,
|
|
1533
|
+
runningOutsideCos,
|
|
1534
|
+
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
1535
|
+
hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/** The attachability body's `cancel`, for a thread id the caller has NOT validated. */
|
|
1539
|
+
const cancelFieldFor = (provider: string, threadId: string, verdictReason: string | null): CancelTarget | null => {
|
|
1540
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return null
|
|
1541
|
+
return cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
|
|
1542
|
+
}
|
|
1543
|
+
/** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
|
|
1544
|
+
const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
|
|
1545
|
+
|
|
1405
1546
|
/**
|
|
1406
1547
|
* One owner for "is this thread free right now", shared by the probe and both
|
|
1407
1548
|
* writes, including its own try/catch and the self-contradiction re-check.
|
|
@@ -1535,15 +1676,24 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1535
1676
|
// fail. It also keeps the surface self-consistent: reporting `attachable: true`
|
|
1536
1677
|
// while the attach route is unrouted would leave a client unable to tell
|
|
1537
1678
|
// whether the thread is free or the feature is off.
|
|
1679
|
+
// 6.53.0: every answer below also says what a cancel would do (`cancelTargetFor`, the
|
|
1680
|
+
// same function the cancel route decides with). Null means no run to cancel, and the
|
|
1681
|
+
// lens then shows no row. Computed on the disabled paths too: a desk run can be
|
|
1682
|
+
// cancelled whether or not COS may write into the thread.
|
|
1683
|
+
const provider = String(req.params.provider ?? '')
|
|
1684
|
+
const threadId = String(req.params.threadId ?? '')
|
|
1685
|
+
const withCancel = (body: AttachabilityBody): AttachabilityWithCancel =>
|
|
1686
|
+
({ ...body, cancel: cancelFieldFor(provider, threadId, body.reason) })
|
|
1687
|
+
|
|
1538
1688
|
if (!attachEnabled) {
|
|
1539
|
-
res.json(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' }))
|
|
1689
|
+
res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'attach_disabled' })))
|
|
1540
1690
|
return
|
|
1541
1691
|
}
|
|
1542
1692
|
|
|
1543
1693
|
if (!canDetect) {
|
|
1544
1694
|
// The mechanism does not exist on this install. Distinct from "it ran and
|
|
1545
1695
|
// found nothing" by design (plan 4.3 wants the reason nameable).
|
|
1546
|
-
res.json(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' }))
|
|
1696
|
+
res.json(withCancel(projectAttachability({ attachable: false, owners: [], reason: 'detector_unavailable' })))
|
|
1547
1697
|
return
|
|
1548
1698
|
}
|
|
1549
1699
|
|
|
@@ -1561,7 +1711,133 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1561
1711
|
// truncated-id hole opened. The tests pin the ordering behaviorally instead:
|
|
1562
1712
|
// probes that throw on every call still return `unsupported_provider` /
|
|
1563
1713
|
// `invalid_thread_id`, which is only possible if nothing was probed.
|
|
1564
|
-
res.json(runOccupancy(
|
|
1714
|
+
res.json(withCancel(runOccupancy(provider, threadId)))
|
|
1715
|
+
})
|
|
1716
|
+
|
|
1717
|
+
// ------------------------------------------------------------------ cancel
|
|
1718
|
+
//
|
|
1719
|
+
// POST /api/agent-sessions/:provider/:threadId/cancel { clientCancelId }
|
|
1720
|
+
//
|
|
1721
|
+
// 6.53.0. Miles, 2026-09-21: "If there's a current run that's going, it essentially
|
|
1722
|
+
// clicks the cancel button ... similar to when they're at the desktop."
|
|
1723
|
+
//
|
|
1724
|
+
// REGISTERED UNCONDITIONALLY, not behind `attachEnabled`: a desk Claude run can be
|
|
1725
|
+
// stopped whether or not COS may write into the thread, and a 404 here is how the app
|
|
1726
|
+
// recognises a server older than 6.53 ("Update the server to cancel runs").
|
|
1727
|
+
//
|
|
1728
|
+
// WHAT IT CAN DO is decided by `cancelTargetFor`, the function the attachability
|
|
1729
|
+
// `cancel` field uses, so the row the lens showed and the answer to the tap agree:
|
|
1730
|
+
// cos_turn 202 {target, state: 'stopping', turnId, queuedHeld, queuedHoldMs}: our own child's
|
|
1731
|
+
// controller is aborted, and the turn route records `turn_cancelled`.
|
|
1732
|
+
// desk_run 202 {target, effective: 'next_tool_call', queuedHeld, queuedHoldMs}: a halt marker
|
|
1733
|
+
// the hook reads before the session's next tool call, and any permission
|
|
1734
|
+
// prompt the broker holds for that session is denied with interrupt.
|
|
1735
|
+
// refusals 409 {reason, reasonCopy}: not_running, cancel_unsupported (Codex app,
|
|
1736
|
+
// Cursor), hooks_outdated (Install hooks), hooks_disabled.
|
|
1737
|
+
//
|
|
1738
|
+
// `queuedHoldMs` is how long the parked turns wait (CANCEL_QUEUE_HOLD_MS), so a client
|
|
1739
|
+
// never hard-codes the two minutes.
|
|
1740
|
+
//
|
|
1741
|
+
// IDEMPOTENT PER clientCancelId: the same tap retried gets the same status and body
|
|
1742
|
+
// (plus `replayed: true`), and does not arm or abort anything twice.
|
|
1743
|
+
//
|
|
1744
|
+
// The body carries no path, no pid and no native id beyond the one in the URL.
|
|
1745
|
+
const CANCEL_REPLY_TTL_MS = 10 * 60_000
|
|
1746
|
+
const MAX_CANCEL_REPLIES = 256
|
|
1747
|
+
router.post('/agent-sessions/:provider/:threadId/cancel', (req, res) => {
|
|
1748
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1749
|
+
const provider = String(req.params.provider ?? '')
|
|
1750
|
+
// Lower-cased BEFORE validation: it names a file and a map key, and the hook
|
|
1751
|
+
// lower-cases the id it reads, so any other spelling could never match.
|
|
1752
|
+
const threadId = String(req.params.threadId ?? '').toLowerCase()
|
|
1753
|
+
const body = plainBody(req)
|
|
1754
|
+
const clientCancelId = body?.clientCancelId
|
|
1755
|
+
if (!isBindableProvider(provider) || !isValidNativeThreadId(threadId)
|
|
1756
|
+
|| typeof clientCancelId !== 'string' || !CLIENT_CANCEL_ID_RE.test(clientCancelId)) {
|
|
1757
|
+
res.status(400).json({ cancelled: false, reason: 'invalid_request', reasonCopy: cancelRefusalCopy('invalid_request', provider) })
|
|
1758
|
+
return
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const now = readNow() ?? Date.now()
|
|
1762
|
+
for (const [k, v] of cancelReplies) if (now - v.at > CANCEL_REPLY_TTL_MS) cancelReplies.delete(k)
|
|
1763
|
+
const replayKey = `${provider}:${threadId}:${clientCancelId}`
|
|
1764
|
+
const replay = cancelReplies.get(replayKey)
|
|
1765
|
+
if (replay) {
|
|
1766
|
+
res.status(replay.status).json({ ...replay.body, replayed: true })
|
|
1767
|
+
return
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const answer = (status: number, target: CancelTarget | null, outcome: 'accepted' | CancelRefusal, payload: Record<string, unknown>): void => {
|
|
1771
|
+
cancelReplies.set(replayKey, { status, body: payload, at: now })
|
|
1772
|
+
while (cancelReplies.size > MAX_CANCEL_REPLIES) {
|
|
1773
|
+
const oldest = cancelReplies.keys().next()
|
|
1774
|
+
if (oldest.done) break
|
|
1775
|
+
cancelReplies.delete(oldest.value)
|
|
1776
|
+
}
|
|
1777
|
+
cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId }), undefined)
|
|
1778
|
+
console.log(`[agent-session-bindings] cancel provider=${provider} target=${target ?? 'none'} outcome=${outcome} status=${status} clientCancelId=${clientCancelId}`)
|
|
1779
|
+
res.status(status).json(payload)
|
|
1780
|
+
}
|
|
1781
|
+
const refuseCancel = (target: CancelTarget | null, reason: CancelRefusal, status = 409): void =>
|
|
1782
|
+
answer(status, target, reason, {
|
|
1783
|
+
cancelled: false,
|
|
1784
|
+
reason,
|
|
1785
|
+
...(reason === 'cancel_unsupported' ? { provider } : {}),
|
|
1786
|
+
reasonCopy: cancelRefusalCopy(reason, provider),
|
|
1787
|
+
})
|
|
1788
|
+
|
|
1789
|
+
// The occupancy verdict is read only when the decision needs it: never for a COS turn
|
|
1790
|
+
// (the map knows) and never for Claude with a desk probe wired (the hooks know).
|
|
1791
|
+
const key = targetKey(provider, threadId)
|
|
1792
|
+
const activeEntry = inFlight.get(key)
|
|
1793
|
+
if (activeEntry && activeEntry.cancellable !== true) {
|
|
1794
|
+
return refuseCancel(null, 'cancel_failed')
|
|
1795
|
+
}
|
|
1796
|
+
const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
|
|
1797
|
+
const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
|
|
1798
|
+
const target = cancelTargetFor(cancelFactsFor(provider, threadId, verdictReason))
|
|
1799
|
+
const queuedHeld = (): number => {
|
|
1800
|
+
const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
|
|
1801
|
+
return Number.isInteger(n) && n > 0 ? n : 0
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
if (target === 'cos_turn') {
|
|
1805
|
+
const entry = inFlight.get(key)
|
|
1806
|
+
if (!entry) return refuseCancel(null, 'not_running')
|
|
1807
|
+
// The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
|
|
1808
|
+
// route records the outcome; this answer does not wait for either.
|
|
1809
|
+
entry.controller.abort()
|
|
1810
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
1811
|
+
return answer(202, target, 'accepted', {
|
|
1812
|
+
cancelled: true,
|
|
1813
|
+
target,
|
|
1814
|
+
state: 'stopping',
|
|
1815
|
+
turnId: entry.turnId,
|
|
1816
|
+
queuedHeld: queuedHeld(),
|
|
1817
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
1818
|
+
})
|
|
1819
|
+
}
|
|
1820
|
+
if (target === 'desk_run') {
|
|
1821
|
+
const armed = cancelProbe(() => cancelDeps.writeHalt?.(threadId, { at: now, clientCancelId }) === true, false)
|
|
1822
|
+
// Not armed means nothing will stop the run: never report it accepted.
|
|
1823
|
+
if (!armed) return refuseCancel(target, 'cancel_failed', 500)
|
|
1824
|
+
// The away-from-desk case: a prompt the broker is holding would otherwise keep the
|
|
1825
|
+
// run waiting for its answer, and then show the Mac's own dialog. Deny it, with
|
|
1826
|
+
// interrupt, so the run ends now rather than at a tool that never comes.
|
|
1827
|
+
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1828
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
1829
|
+
return answer(202, target, 'accepted', {
|
|
1830
|
+
cancelled: true,
|
|
1831
|
+
target,
|
|
1832
|
+
effective: 'next_tool_call',
|
|
1833
|
+
queuedHeld: queuedHeld(),
|
|
1834
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
1835
|
+
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1836
|
+
})
|
|
1837
|
+
}
|
|
1838
|
+
if (target === 'unsupported') return refuseCancel(target, 'cancel_unsupported')
|
|
1839
|
+
if (target === 'hooks_outdated' || target === 'hooks_disabled') return refuseCancel(target, target)
|
|
1840
|
+
return refuseCancel(null, 'not_running')
|
|
1565
1841
|
})
|
|
1566
1842
|
|
|
1567
1843
|
router.get('/agent-sessions/bindings', (_req, res) => {
|
|
@@ -1952,6 +2228,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1952
2228
|
`[agent-session-bindings] turn timing turnId=${turnId} outcome=${outcome} total=${Number((process.hrtime.bigint() - stageClock) / 1_000_000n)}ms ${stages.join(' ')}`
|
|
1953
2229
|
/** The target we hold a claim on, released in the finally. */
|
|
1954
2230
|
let claimedKey: string | null = null
|
|
2231
|
+
/** 6.53.0: this turn's cancel. Registered in `inFlight` at the claim, deleted in the finally. */
|
|
2232
|
+
const cancelController = new AbortController()
|
|
1955
2233
|
// Hoisted so the CATCH site can fence with evidence. `binding` and `head` are
|
|
1956
2234
|
// both declared inside the try, so neither is in scope where the route-error
|
|
1957
2235
|
// fence is set — without these it would store a fence it can say nothing about.
|
|
@@ -2167,6 +2445,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2167
2445
|
// see a free target and both deliver.
|
|
2168
2446
|
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
2169
2447
|
claimedKey = key
|
|
2448
|
+
// 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
|
|
2449
|
+
inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true })
|
|
2170
2450
|
|
|
2171
2451
|
// Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
|
|
2172
2452
|
// session started in the gap is exactly the residual risk option B leaves
|
|
@@ -2212,6 +2492,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2212
2492
|
if (!pinned?.binding) return refuseTurn(registryRefusal(pinned?.reason))
|
|
2213
2493
|
pinnedBindingId = bindingId
|
|
2214
2494
|
|
|
2495
|
+
// 6.53.0: a cancel that landed during the gate or the head read. Before the live hop,
|
|
2496
|
+
// which cannot be recalled once it writes into the open session. Nothing was sent;
|
|
2497
|
+
// not retryable, because the person asked for this turn to stop.
|
|
2498
|
+
if (cancelController.signal.aborted) {
|
|
2499
|
+
console.log(`[agent-session-bindings] turn cancelled before delivery provider=${binding.provider} turnId=${turnId} bindingId=${bindingId}`)
|
|
2500
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2215
2503
|
// 6.49.0: LIVE FIRST. If the session's own process is running -- a Desktop tab,
|
|
2216
2504
|
// a `claude` in iTerm, the same registry record either way -- the turn goes into
|
|
2217
2505
|
// it and paints where the user left the window, instead of into a resume child
|
|
@@ -2225,6 +2513,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2225
2513
|
// on its own clock; the next interactive turn reads that as a change and asks,
|
|
2226
2514
|
// which is the conservative side. Queued turns attach fresh and never notice.
|
|
2227
2515
|
if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
|
|
2516
|
+
const liveEntry = inFlight.get(key)
|
|
2517
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2228
2518
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
|
|
2229
2519
|
try {
|
|
2230
2520
|
live = await deps.deliverLiveTurn({
|
|
@@ -2260,6 +2550,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2260
2550
|
// Something may be in the session. Hold, never spawn over it.
|
|
2261
2551
|
return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
|
|
2262
2552
|
}
|
|
2553
|
+
// Every remaining result is documented as "nothing reached a session";
|
|
2554
|
+
// the abortable child path is safe to expose again before it starts.
|
|
2555
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2263
2556
|
// disabled / not_claude / no_record / protocol_unsupported / connect_failed /
|
|
2264
2557
|
// suspect_expired: nothing reached a session. Spawn as always.
|
|
2265
2558
|
}
|
|
@@ -2272,6 +2565,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2272
2565
|
// current turn, or within seconds if the thread is idle (canary in
|
|
2273
2566
|
// `codex-live-queue.ts`). A thread nobody holds never gets here.
|
|
2274
2567
|
if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
|
|
2568
|
+
const liveEntry = inFlight.get(key)
|
|
2569
|
+
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2275
2570
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
|
|
2276
2571
|
try {
|
|
2277
2572
|
live = await deps.deliverCodexLiveTurn({
|
|
@@ -2300,6 +2595,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2300
2595
|
})
|
|
2301
2596
|
return
|
|
2302
2597
|
}
|
|
2598
|
+
// The operator switched this off: nothing was queued, so the abortable
|
|
2599
|
+
// 6.50 child path is cancellable again before it starts.
|
|
2600
|
+
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2303
2601
|
// The operator switched this off: the 6.50 path, unchanged, whatever it does.
|
|
2304
2602
|
if (live?.reason !== 'disabled') {
|
|
2305
2603
|
// A throw, a timeout, or a confirmation we could not read: a row MAY be in the
|
|
@@ -2370,6 +2668,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2370
2668
|
sourceFingerprint: binding.sourceFingerprint,
|
|
2371
2669
|
expectedNativeHead: head.raw,
|
|
2372
2670
|
prompt,
|
|
2671
|
+
abortSignal: cancelController.signal,
|
|
2373
2672
|
onSpawn: (pid: number): boolean => {
|
|
2374
2673
|
// THE SELF-RECURSION ORDER. The child registers itself against the id
|
|
2375
2674
|
// we are targeting, so unless it is in the ledger the next occupancy
|
|
@@ -2424,12 +2723,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2424
2723
|
stderrClass: typeof r.stderrClass === 'string' ? r.stderrClass : undefined,
|
|
2425
2724
|
durationMs: typeof r.durationMs === 'number' ? r.durationMs : undefined,
|
|
2426
2725
|
}
|
|
2427
|
-
delivery = classifyDelivery(result)
|
|
2726
|
+
delivery = classifyDelivery(result, cancelController.signal.aborted)
|
|
2428
2727
|
} catch (error) {
|
|
2429
2728
|
console.error(`[agent-session-bindings] adapter threw: ${error instanceof Error ? error.message : error}`)
|
|
2430
2729
|
delivery = { kind: 'ambiguous' }
|
|
2431
2730
|
}
|
|
2432
2731
|
|
|
2732
|
+
// 6.53.0: CANCELLED, BEFORE THE FENCE. An aborted child exits on a signal, which
|
|
2733
|
+
// without this branch is `ambiguous` and fences the thread (plan validation B2):
|
|
2734
|
+
// the person who pressed Cancel would find the thread they stopped locked until
|
|
2735
|
+
// someone releases a fence. A cancel is not an unknown. The turn is ledgered as
|
|
2736
|
+
// refused, `turn_cancelled`, not retryable, which every app since 6.9.4xx settles.
|
|
2737
|
+
if (delivery.kind === 'cancelled') {
|
|
2738
|
+
// The head moved if the child wrote anything before the stop. Acknowledge it the
|
|
2739
|
+
// way a completed turn does, so the next Continue on this binding is not refused
|
|
2740
|
+
// `native_thread_changed` for the run the person just cancelled.
|
|
2741
|
+
const reread = await readHead(binding.provider, binding.nativeThreadId)
|
|
2742
|
+
if (reread !== null) guard.acknowledgeHead(bindingId, reread.digest)
|
|
2743
|
+
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}`)
|
|
2744
|
+
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2433
2747
|
if (delivery.kind === 'aborted') return refuseTurn('provider_never_opened')
|
|
2434
2748
|
|
|
2435
2749
|
if (delivery.kind === 'ambiguous') {
|
|
@@ -2536,6 +2850,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2536
2850
|
console.error(`[agent-session-bindings] unpin failed: ${error instanceof Error ? error.message : error}`)
|
|
2537
2851
|
}
|
|
2538
2852
|
}
|
|
2853
|
+
// By turnId: only this turn's own entry, never a successor's.
|
|
2854
|
+
if (claimedKey !== null && inFlight.get(claimedKey)?.turnId === turnId) inFlight.delete(claimedKey)
|
|
2539
2855
|
if (claimedKey !== null) guard.release(claimedKey, turnId)
|
|
2540
2856
|
}
|
|
2541
2857
|
})
|
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
|