@gotcos/glasses-server 6.53.1 → 6.53.3
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 +24 -0
- package/bin/hooks/cos-session-hook +74 -38
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +500 -176
- package/server/lib/claude-hooks-installer.ts +52 -0
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +94 -0
- package/server/lib/session-hooks-runtime.ts +55 -2
- package/server/routes/agent-session-bindings.ts +262 -17
- package/server/routes/health.ts +2 -1
- package/server/routes/meeting.ts +13 -4
- package/server/routes/transcribe-stream.ts +29 -1
- package/server/scripts/hooks-cli.ts +6 -2
|
@@ -125,7 +125,7 @@ import {
|
|
|
125
125
|
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
126
126
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
127
127
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
|
-
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
128
|
+
import { PEER_VERIFY_TIMEOUT_MS, peerAcceptanceMarker } from '../lib/session-peer-inbox.js'
|
|
129
129
|
import {
|
|
130
130
|
CANCEL_QUEUE_HOLD_MS,
|
|
131
131
|
CLIENT_CANCEL_ID_RE,
|
|
@@ -450,8 +450,17 @@ export interface CancelDeps {
|
|
|
450
450
|
settlePermissions?: (sessionId: string) => number
|
|
451
451
|
/** Waiting turns parked on the thread (they hold for CANCEL_QUEUE_HOLD_MS). */
|
|
452
452
|
queuedWaiting?: (provider: string, threadId: string) => number
|
|
453
|
-
/**
|
|
454
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Record the cancel the turn queue reads (`noteThreadCancelled`). 6.53.3: with its TARGET,
|
|
455
|
+
* because a killed COS child's silence ends its turn and a desk run's does not (review A1).
|
|
456
|
+
*/
|
|
457
|
+
noteCancelled?: (provider: string, threadId: string, at: number, target: CancelTarget) => void
|
|
458
|
+
/**
|
|
459
|
+
* 6.53.3 (review A6): a cancel latched during a live hand-off whose turn reached (or may
|
|
460
|
+
* have reached) the open session. Writes the halt marker, and re-arms it once when that
|
|
461
|
+
* turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
|
|
462
|
+
*/
|
|
463
|
+
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { promptMarker: string; sentAt: number }) => boolean
|
|
455
464
|
/** Append to `data/session-cancel.jsonl`. */
|
|
456
465
|
ledger?: (row: SessionCancelLedgerRow) => void
|
|
457
466
|
}
|
|
@@ -998,6 +1007,14 @@ export interface TargetFenceView {
|
|
|
998
1007
|
fencedReason(targetKey: string): WriteRefusal | null
|
|
999
1008
|
}
|
|
1000
1009
|
|
|
1010
|
+
/** 6.53.3: how often a read may re-probe a settled-cancel fence's children (`ps` per child). */
|
|
1011
|
+
export const CANCEL_FENCE_RECHECK_MS = 30_000
|
|
1012
|
+
|
|
1013
|
+
/** A fence a cancel left at the delivery-ambiguous site: the only kind `releaseSettledCancelFences` may touch. */
|
|
1014
|
+
function isSettledCancelCandidate(row: FenceRecord): boolean {
|
|
1015
|
+
return row.adapterReason === 'cancelled' && row.fenceSite === 'ambiguous'
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1001
1018
|
export class TargetGuard {
|
|
1002
1019
|
/** targetKey -> turnId of the single COS turn allowed to be in flight. */
|
|
1003
1020
|
private readonly claims = new Map<string, string>()
|
|
@@ -1006,6 +1023,9 @@ export class TargetGuard {
|
|
|
1006
1023
|
private readonly fences = new Map<string, FenceRecord>()
|
|
1007
1024
|
private readonly persistence: FencePersistence | null
|
|
1008
1025
|
private persistDegraded = false
|
|
1026
|
+
/** 6.53.3: set by the router; null keeps `fencedReason` a pure read (tests, older wiring). */
|
|
1027
|
+
private cancelLiveness: FenceLivenessDeps | null = null
|
|
1028
|
+
private cancelSweepAt = Number.NEGATIVE_INFINITY
|
|
1009
1029
|
|
|
1010
1030
|
constructor(persistence: FencePersistence | null = null) {
|
|
1011
1031
|
this.persistence = persistence
|
|
@@ -1099,7 +1119,63 @@ export class TargetGuard {
|
|
|
1099
1119
|
|
|
1100
1120
|
fencedReason(targetKey: string): WriteRefusal | null {
|
|
1101
1121
|
const row = this.fences.get(targetKey)
|
|
1102
|
-
|
|
1122
|
+
if (row === undefined) return null
|
|
1123
|
+
// 6.53.3: a settled-cancel fence is re-checked here too, throttled, so a thread whose
|
|
1124
|
+
// cancelled child has since exited opens on its next use, not only on a restart or when
|
|
1125
|
+
// someone opens the Fences card.
|
|
1126
|
+
if (this.cancelLiveness !== null && isSettledCancelCandidate(row)) {
|
|
1127
|
+
const now = Date.now()
|
|
1128
|
+
if (now - this.cancelSweepAt >= CANCEL_FENCE_RECHECK_MS) {
|
|
1129
|
+
this.cancelSweepAt = now
|
|
1130
|
+
this.releaseSettledCancelFences(this.cancelLiveness)
|
|
1131
|
+
if (!this.fences.has(targetKey)) return null
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return row.reason as WriteRefusal
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* 6.53.3 (review A4): release every fence a CANCEL left behind whose recorded children
|
|
1139
|
+
* are all provably gone (`fenceLiveness` says `none_running`), and log each one with its
|
|
1140
|
+
* whole evidence record, as the operator release does.
|
|
1141
|
+
*
|
|
1142
|
+
* WHY THIS ONE CLASS, AND ONLY IT. A fence exists because a turn's delivery is unknowable
|
|
1143
|
+
* and a second copy could reach a real conversation. For a cancel that question is moot:
|
|
1144
|
+
* the person asked for the turn to stop, and the route settles it cancelled, never
|
|
1145
|
+
* retried. What remains is a second WRITER, and only the recorded child (the CLI, the one
|
|
1146
|
+
* process that writes the transcript) can be one. 6.53.1 fenced every Claude, Codex and
|
|
1147
|
+
* Cursor cancel whose tool was churning subprocesses (review A4: 10/10 false fences per
|
|
1148
|
+
* variant), and those fences are durable across restarts and upgrades.
|
|
1149
|
+
*
|
|
1150
|
+
* NEVER: a fence whose adapter reason is not `cancelled` (a timeout had 21 minutes of
|
|
1151
|
+
* tool calls; that is still a person's call), one set at the route-error site (a route
|
|
1152
|
+
* bug around a delivery), one whose children were never recorded or could not be probed
|
|
1153
|
+
* (`unknown`), or one with a child still alive (`running`). Returns what was released.
|
|
1154
|
+
*/
|
|
1155
|
+
releaseSettledCancelFences(deps: FenceLivenessDeps, log: (line: string) => void = line => console.warn(line)): FenceRecord[] {
|
|
1156
|
+
const released: FenceRecord[] = []
|
|
1157
|
+
for (const row of [...this.fences.values()]) {
|
|
1158
|
+
if (!isSettledCancelCandidate(row)) continue
|
|
1159
|
+
let state: string
|
|
1160
|
+
try {
|
|
1161
|
+
state = fenceLiveness(row.spawns, deps).state
|
|
1162
|
+
} catch {
|
|
1163
|
+
continue
|
|
1164
|
+
}
|
|
1165
|
+
if (state !== 'none_running') continue
|
|
1166
|
+
const target = opaqueRevision(row.targetKey)
|
|
1167
|
+
const outcome = this.releaseFence(target)
|
|
1168
|
+
if (!outcome.ok) continue
|
|
1169
|
+
const ev = outcome.row
|
|
1170
|
+
released.push(ev)
|
|
1171
|
+
log(`[agent-session-bindings] fence RELEASED automatically (cancelled turn, every recorded child gone) target=${target} provider=${ev.provider} fencedAt=${ev.fencedAt} fenceSite=${ev.fenceSite ?? 'unknown'} adapterReason=${ev.adapterReason ?? 'unknown'} detail=${ev.adapterDetail ?? 'none'} exitCode=${ev.exitCode ?? 'null'} childReaped=${ev.childReaped ?? 'unknown'} stderrClass=${ev.stderrClass ?? 'none'} durationMs=${ev.durationMs ?? 'unknown'} spawnCount=${ev.spawns?.length ?? 0}`)
|
|
1172
|
+
}
|
|
1173
|
+
return released
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** 6.53.3: the probe the lazy re-check in `fencedReason` uses; the router adopts its own. */
|
|
1177
|
+
adoptCancelLiveness(deps: FenceLivenessDeps): void {
|
|
1178
|
+
this.cancelLiveness = deps
|
|
1103
1179
|
}
|
|
1104
1180
|
|
|
1105
1181
|
/** Every fence, REDACTED for the wire: the raw targetKey embeds the private
|
|
@@ -1190,6 +1266,21 @@ interface InFlightTurn {
|
|
|
1190
1266
|
startedAt: number
|
|
1191
1267
|
/** False while a live hand-off is crossing its irreversible write boundary. */
|
|
1192
1268
|
cancellable: boolean
|
|
1269
|
+
/**
|
|
1270
|
+
* 6.53.3 (review A6): a cancel that arrived while `cancellable` was false. It is LATCHED
|
|
1271
|
+
* (answered 202 `stopping`) and settled by the turn route once the hop says where the
|
|
1272
|
+
* turn went: nothing reached, `turn_cancelled` and no spawn; reached, the desk halt
|
|
1273
|
+
* marker. Until 6.53.2 it was refused 409 `cancel_failed`, that refusal was cached for the
|
|
1274
|
+
* tap, and a hop that reached nothing went on to spawn the turn the person had stopped.
|
|
1275
|
+
*/
|
|
1276
|
+
latched: LatchedCancel | null
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
interface LatchedCancel {
|
|
1280
|
+
at: number
|
|
1281
|
+
clientCancelId: string
|
|
1282
|
+
/** Every tap latched onto this turn: each one's cached answer is rewritten to the outcome. */
|
|
1283
|
+
replayKeys: string[]
|
|
1193
1284
|
}
|
|
1194
1285
|
|
|
1195
1286
|
type Delivery =
|
|
@@ -1480,6 +1571,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1480
1571
|
}
|
|
1481
1572
|
}),
|
|
1482
1573
|
}
|
|
1574
|
+
// 6.53.3 (review A4): at boot, release the fences a cancel left whose children are all
|
|
1575
|
+
// gone (6.53.1's false "unreaped" among them), then keep re-checking on every fence read.
|
|
1576
|
+
guard.adoptCancelLiveness(livenessDeps)
|
|
1577
|
+
try {
|
|
1578
|
+
guard.releaseSettledCancelFences(livenessDeps)
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1581
|
+
}
|
|
1483
1582
|
const ownership = deps?.ownership ?? { record: recordCosSpawn, release: releaseCosSpawn }
|
|
1484
1583
|
// One per router. Injectable so the follow-on (attach accepting a `forkRef`)
|
|
1485
1584
|
// shares this instance rather than standing up a second, disconnected one.
|
|
@@ -1524,10 +1623,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1524
1623
|
*/
|
|
1525
1624
|
const cancelFactsFor = (provider: string, threadId: string, verdictReason: string | null): CancelFacts => {
|
|
1526
1625
|
const entry = inFlight.get(targetKey(provider, threadId))
|
|
1527
|
-
|
|
1626
|
+
// 6.53.3: a live hand-off in flight is still OUR turn, and a cancel on it is latched
|
|
1627
|
+
// (review A6), so it reads cos_turn too, never as a desk run while it commits.
|
|
1628
|
+
const cosTurnInFlight = entry !== undefined
|
|
1528
1629
|
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
1630
|
if (!entry) {
|
|
1532
1631
|
runningOutsideCos = provider === 'claude' && typeof cancelDeps.deskRunning === 'function'
|
|
1533
1632
|
? cancelProbe(() => cancelDeps.deskRunning!(threadId) === true, false)
|
|
@@ -1548,6 +1647,90 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1548
1647
|
}
|
|
1549
1648
|
/** Idempotency for POST .../cancel: the answer each (thread, clientCancelId) got. */
|
|
1550
1649
|
const cancelReplies = new Map<string, { status: number; body: Record<string, unknown>; at: number }>()
|
|
1650
|
+
const queuedHeldFor = (provider: string, threadId: string): number => {
|
|
1651
|
+
const n = cancelProbe(() => cancelDeps.queuedWaiting?.(provider, threadId) ?? 0, 0)
|
|
1652
|
+
return Number.isInteger(n) && n > 0 ? n : 0
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* 6.53.3 (review A6): settle a cancel LATCHED during a live hand-off, once the hop has said
|
|
1657
|
+
* where the turn went, and rewrite the answer every latched tap replays to that outcome
|
|
1658
|
+
* (never the transient 202 alone, never a refusal from before the latch).
|
|
1659
|
+
*
|
|
1660
|
+
* none nothing reached a session: the turn route answers `turn_cancelled` and never
|
|
1661
|
+
* spawns. The cached 202 `stopping` already says so.
|
|
1662
|
+
* reached the turn is in the open session (or MAY be: an unverified write). What stops it
|
|
1663
|
+
* / maybe there is what stops any desk run: the halt marker, re-armed once through the
|
|
1664
|
+
* turn's own UserPromptSubmit. Codex and Cursor have no marker, so the answer is
|
|
1665
|
+
* the ordinary `cancel_unsupported` ("Stop it there"); hooks that cannot stop a
|
|
1666
|
+
* desk run answer their own reason; a marker that could not be written is
|
|
1667
|
+
* `cancel_failed` 500, exactly as on the desk path.
|
|
1668
|
+
*/
|
|
1669
|
+
const settleLatchedCancel = (
|
|
1670
|
+
entry: InFlightTurn,
|
|
1671
|
+
provider: string,
|
|
1672
|
+
threadId: string,
|
|
1673
|
+
reached: 'none' | 'reached' | 'maybe',
|
|
1674
|
+
turn: { prompt: string; sentAt: number },
|
|
1675
|
+
): void => {
|
|
1676
|
+
const latch = entry.latched
|
|
1677
|
+
if (latch === null) return
|
|
1678
|
+
const now = readNow() ?? Date.now()
|
|
1679
|
+
const common = { turnId: entry.turnId, queuedHeld: queuedHeldFor(provider, threadId), queuedHoldMs: CANCEL_QUEUE_HOLD_MS }
|
|
1680
|
+
let status = 202
|
|
1681
|
+
let target: CancelTarget = 'cos_turn'
|
|
1682
|
+
let outcome: 'accepted' | CancelRefusal = 'accepted'
|
|
1683
|
+
let body: Record<string, unknown> = { cancelled: true, target, state: 'stopping', ...common }
|
|
1684
|
+
if (reached !== 'none') {
|
|
1685
|
+
target = cancelTargetFor({
|
|
1686
|
+
provider,
|
|
1687
|
+
cosTurnInFlight: false,
|
|
1688
|
+
runningOutsideCos: true,
|
|
1689
|
+
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
1690
|
+
hooksReady: cancelProbe(() => cancelDeps.hooksReady?.() === true, false),
|
|
1691
|
+
}) ?? 'unsupported'
|
|
1692
|
+
if (target === 'desk_run') {
|
|
1693
|
+
const armed = cancelProbe(() => cancelDeps.haltHandedOffTurn?.(threadId, { at: latch.at, clientCancelId: latch.clientCancelId }, {
|
|
1694
|
+
promptMarker: peerAcceptanceMarker(turn.prompt),
|
|
1695
|
+
sentAt: turn.sentAt,
|
|
1696
|
+
}) === true, false)
|
|
1697
|
+
if (armed) {
|
|
1698
|
+
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1699
|
+
body = {
|
|
1700
|
+
cancelled: true,
|
|
1701
|
+
target,
|
|
1702
|
+
effective: 'next_tool_call',
|
|
1703
|
+
...common,
|
|
1704
|
+
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1705
|
+
}
|
|
1706
|
+
} else {
|
|
1707
|
+
status = 500
|
|
1708
|
+
outcome = 'cancel_failed'
|
|
1709
|
+
}
|
|
1710
|
+
} else {
|
|
1711
|
+
status = 409
|
|
1712
|
+
outcome = target === 'hooks_outdated' || target === 'hooks_disabled' ? target : 'cancel_unsupported'
|
|
1713
|
+
}
|
|
1714
|
+
if (outcome !== 'accepted') {
|
|
1715
|
+
body = {
|
|
1716
|
+
cancelled: false,
|
|
1717
|
+
reason: outcome,
|
|
1718
|
+
...(outcome === 'cancel_unsupported' ? { provider } : {}),
|
|
1719
|
+
reasonCopy: cancelRefusalCopy(outcome, provider),
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
for (const replayKey of latch.replayKeys) {
|
|
1724
|
+
const previous = cancelReplies.get(replayKey)
|
|
1725
|
+
cancelReplies.set(replayKey, { status, body, at: previous?.at ?? now })
|
|
1726
|
+
}
|
|
1727
|
+
// The first answer was ledgered `accepted`; a cancel that then could not be carried out
|
|
1728
|
+
// gets a line of its own, so the ledger never records a stop that did not happen.
|
|
1729
|
+
if (outcome !== 'accepted') {
|
|
1730
|
+
cancelProbe(() => cancelDeps.ledger?.({ at: new Date(now).toISOString(), provider, threadId, target, outcome, clientCancelId: latch.clientCancelId }), undefined)
|
|
1731
|
+
}
|
|
1732
|
+
console.log(`[agent-session-bindings] latched cancel settled provider=${provider} turnId=${entry.turnId} hop=${reached} target=${target} outcome=${outcome} status=${status} taps=${latch.replayKeys.length}`)
|
|
1733
|
+
}
|
|
1551
1734
|
|
|
1552
1735
|
/**
|
|
1553
1736
|
* One owner for "is this thread free right now", shared by the probe and both
|
|
@@ -1630,6 +1813,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1630
1813
|
// `degraded` is reported, never inferred: a memory-only fence set behaves
|
|
1631
1814
|
// identically to a durable one until the process restarts, so a silent
|
|
1632
1815
|
// fallback would be indistinguishable from working.
|
|
1816
|
+
// 6.53.3: a read releases the settled-cancel fences first (logged), so the card never
|
|
1817
|
+
// offers a person a fence the server can prove is safe to lift.
|
|
1818
|
+
try {
|
|
1819
|
+
guard.releaseSettledCancelFences(livenessDeps)
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
console.error(`[agent-session-bindings] settled-cancel fence sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1822
|
+
}
|
|
1633
1823
|
res.json({ fences: guard.listFences(livenessDeps), degraded: guard.degraded() })
|
|
1634
1824
|
})
|
|
1635
1825
|
|
|
@@ -1795,17 +1985,33 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1795
1985
|
// The occupancy verdict is read only when the decision needs it: never for a COS turn
|
|
1796
1986
|
// (the map knows) and never for Claude with a desk probe wired (the hooks know).
|
|
1797
1987
|
const key = targetKey(provider, threadId)
|
|
1988
|
+
const queuedHeld = (): number => queuedHeldFor(provider, threadId)
|
|
1798
1989
|
const activeEntry = inFlight.get(key)
|
|
1799
1990
|
if (activeEntry && activeEntry.cancellable !== true) {
|
|
1800
|
-
|
|
1991
|
+
// 6.53.3 (review A6): the turn is crossing a live hand-off's write boundary, where no
|
|
1992
|
+
// abort can recall it. LATCH the cancel on the turn and answer as for any COS turn;
|
|
1993
|
+
// the turn route settles it when the hop reports (`settleLatchedCancel`). The abort
|
|
1994
|
+
// is still signalled, so nothing after the hop can start the turn either.
|
|
1995
|
+
if (activeEntry.latched === null) {
|
|
1996
|
+
activeEntry.latched = { at: now, clientCancelId, replayKeys: [] }
|
|
1997
|
+
activeEntry.controller.abort()
|
|
1998
|
+
// Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
|
|
1999
|
+
// its app can stop. Either way only the engine's own end discounts its open turn.
|
|
2000
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, provider === 'claude' ? 'desk_run' : 'unsupported'), undefined)
|
|
2001
|
+
}
|
|
2002
|
+
activeEntry.latched.replayKeys.push(replayKey)
|
|
2003
|
+
return answer(202, 'cos_turn', 'accepted', {
|
|
2004
|
+
cancelled: true,
|
|
2005
|
+
target: 'cos_turn',
|
|
2006
|
+
state: 'stopping',
|
|
2007
|
+
turnId: activeEntry.turnId,
|
|
2008
|
+
queuedHeld: queuedHeld(),
|
|
2009
|
+
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
2010
|
+
})
|
|
1801
2011
|
}
|
|
1802
2012
|
const needsVerdict = !inFlight.has(key) && !(provider === 'claude' && typeof cancelDeps.deskRunning === 'function')
|
|
1803
2013
|
const verdictReason = needsVerdict && canDetect ? projectAttachability(detectOccupancy(provider, threadId)).reason : null
|
|
1804
2014
|
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
2015
|
|
|
1810
2016
|
if (target === 'cos_turn') {
|
|
1811
2017
|
const entry = inFlight.get(key)
|
|
@@ -1813,7 +2019,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1813
2019
|
// The adapter stops the process group (SIGTERM, then SIGKILL after 5 s) and the turn
|
|
1814
2020
|
// route records the outcome; this answer does not wait for either.
|
|
1815
2021
|
entry.controller.abort()
|
|
1816
|
-
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
2022
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
|
|
1817
2023
|
return answer(202, target, 'accepted', {
|
|
1818
2024
|
cancelled: true,
|
|
1819
2025
|
target,
|
|
@@ -1831,7 +2037,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1831
2037
|
// run waiting for its answer, and then show the Mac's own dialog. Deny it, with
|
|
1832
2038
|
// interrupt, so the run ends now rather than at a tool that never comes.
|
|
1833
2039
|
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
1834
|
-
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now), undefined)
|
|
2040
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, target), undefined)
|
|
1835
2041
|
return answer(202, target, 'accepted', {
|
|
1836
2042
|
cancelled: true,
|
|
1837
2043
|
target,
|
|
@@ -2452,7 +2658,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2452
2658
|
if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
|
|
2453
2659
|
claimedKey = key
|
|
2454
2660
|
// 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 })
|
|
2661
|
+
inFlight.set(key, { turnId, bindingId, clientTurnId, controller: cancelController, startedAt: now, cancellable: true, latched: null })
|
|
2456
2662
|
|
|
2457
2663
|
// Plan 4.3 step 6. The attach-time verdict is minutes old by now; a desktop
|
|
2458
2664
|
// session started in the gap is exactly the residual risk option B leaves
|
|
@@ -2521,6 +2727,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2521
2727
|
if (typeof deps.deliverLiveTurn === 'function' && binding.provider === 'claude') {
|
|
2522
2728
|
const liveEntry = inFlight.get(key)
|
|
2523
2729
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2730
|
+
/** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
|
|
2731
|
+
const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
|
|
2732
|
+
const handOffAt = Date.now()
|
|
2524
2733
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; pid?: number | null } | null = null
|
|
2525
2734
|
try {
|
|
2526
2735
|
live = await deps.deliverLiveTurn({
|
|
@@ -2538,6 +2747,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2538
2747
|
}
|
|
2539
2748
|
stage(`live(${live?.reason ?? 'threw'})`)
|
|
2540
2749
|
if (live?.ok) {
|
|
2750
|
+
// 6.53.3: delivered, so the turn runs in the open session now, and what stops it
|
|
2751
|
+
// there is the desk halt marker. The turn itself WAS delivered and says so.
|
|
2752
|
+
const latched = latchedHere()
|
|
2753
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
2541
2754
|
console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
|
|
2542
2755
|
respond(200, {
|
|
2543
2756
|
turnId,
|
|
@@ -2553,11 +2766,27 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2553
2766
|
return
|
|
2554
2767
|
}
|
|
2555
2768
|
if (live && (live.reason === 'unverified' || live.reason === 'write_failed')) {
|
|
2769
|
+
// 6.53.3: something MAY be in the session and the person asked for it to stop: arm
|
|
2770
|
+
// the marker as for a landed turn, and settle this turn as cancelled, never as a
|
|
2771
|
+
// retryable hold that the queue would send again after the cancel's two minutes.
|
|
2772
|
+
const latched = latchedHere()
|
|
2773
|
+
if (latched) {
|
|
2774
|
+
settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
2775
|
+
return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'unknown' })
|
|
2776
|
+
}
|
|
2556
2777
|
// Something may be in the session. Hold, never spawn over it.
|
|
2557
2778
|
return refuseTurn('live_unverified', { retryable: true, deliveryState: 'unknown' })
|
|
2558
2779
|
}
|
|
2559
|
-
// Every remaining result is documented as "nothing reached a session"
|
|
2560
|
-
//
|
|
2780
|
+
// Every remaining result is documented as "nothing reached a session".
|
|
2781
|
+
// 6.53.3: with a cancel latched on the way, that is the whole turn: cancelled, no
|
|
2782
|
+
// spawn. Until 6.53.2 the refused cancel was forgotten here and the child ran.
|
|
2783
|
+
const latched = latchedHere()
|
|
2784
|
+
if (latched) {
|
|
2785
|
+
settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
2786
|
+
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} hop=${live?.reason ?? 'threw'}`)
|
|
2787
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2788
|
+
}
|
|
2789
|
+
// The abortable child path is safe to expose again before it starts.
|
|
2561
2790
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
2562
2791
|
// disabled / not_claude / no_record / protocol_unsupported / connect_failed /
|
|
2563
2792
|
// suspect_expired: nothing reached a session. Spawn as always.
|
|
@@ -2573,6 +2802,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2573
2802
|
if (binding.provider === 'codex' && foreignHolder && typeof deps.deliverCodexLiveTurn === 'function') {
|
|
2574
2803
|
const liveEntry = inFlight.get(key)
|
|
2575
2804
|
if (liveEntry?.turnId === turnId) liveEntry.cancellable = false
|
|
2805
|
+
/** 6.53.3: a cancel latched onto THIS turn while the hop was in flight (review A6). */
|
|
2806
|
+
const latchedHere = (): InFlightTurn | null => (liveEntry?.turnId === turnId && liveEntry.latched !== null ? liveEntry : null)
|
|
2807
|
+
const handOffAt = Date.now()
|
|
2576
2808
|
let live: { ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null } | null = null
|
|
2577
2809
|
try {
|
|
2578
2810
|
live = await deps.deliverCodexLiveTurn({
|
|
@@ -2587,6 +2819,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2587
2819
|
}
|
|
2588
2820
|
stage(`codex-live(${live?.reason ?? 'threw'})`)
|
|
2589
2821
|
if (live?.ok) {
|
|
2822
|
+
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
2823
|
+
const latched = latchedHere()
|
|
2824
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
2590
2825
|
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
2591
2826
|
respond(200, {
|
|
2592
2827
|
turnId,
|
|
@@ -2601,6 +2836,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2601
2836
|
})
|
|
2602
2837
|
return
|
|
2603
2838
|
}
|
|
2839
|
+
// 6.53.3: anything but a proven or possible queue row reached nothing; with a cancel
|
|
2840
|
+
// latched on the way that is the whole turn: cancelled, and no child.
|
|
2841
|
+
const codexLatched = latchedHere()
|
|
2842
|
+
if (codexLatched && live !== null && live.reason !== 'unverified') {
|
|
2843
|
+
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
2844
|
+
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
2845
|
+
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2846
|
+
}
|
|
2604
2847
|
// The operator switched this off: nothing was queued, so the abortable
|
|
2605
2848
|
// 6.50 child path is cancellable again before it starts.
|
|
2606
2849
|
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
@@ -2624,6 +2867,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2624
2867
|
spawns: [],
|
|
2625
2868
|
})
|
|
2626
2869
|
console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
|
|
2870
|
+
// 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
|
|
2871
|
+
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
2627
2872
|
return reportAmbiguous()
|
|
2628
2873
|
}
|
|
2629
2874
|
// Nothing was queued, and the child cannot run against a held thread. Not
|
package/server/routes/health.ts
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
} from '../lib/thread-attach-capability.js'
|
|
83
83
|
import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
84
84
|
import { sessionCancelFeature } from '../lib/session-cancel.js'
|
|
85
|
+
import { hookHaltReady } from '../lib/claude-hooks-installer.js'
|
|
85
86
|
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
86
87
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
87
88
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
@@ -297,7 +298,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
297
298
|
// 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
|
|
298
299
|
// applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
|
|
299
300
|
// The status read is the same cached one `sessionHooks` below reports.
|
|
300
|
-
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), cachedHookStatus()
|
|
301
|
+
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus())),
|
|
301
302
|
}
|
|
302
303
|
const voice = {
|
|
303
304
|
available: keyStatus.hasKey || tts_local.ready,
|
package/server/routes/meeting.ts
CHANGED
|
@@ -182,8 +182,12 @@ interface MeetingSessionSource {
|
|
|
182
182
|
hasAudio(sessionId: string): boolean
|
|
183
183
|
moveAudioToPending(sessionId: string): string | null
|
|
184
184
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
185
|
+
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
186
|
+
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
|
|
190
|
+
|
|
187
191
|
export interface MeetingRouteDependencies {
|
|
188
192
|
store?: MeetingStore
|
|
189
193
|
sessions?: MeetingSessionSource
|
|
@@ -404,6 +408,7 @@ const defaultSessionSource: MeetingSessionSource = {
|
|
|
404
408
|
hasAudio: hasSessionAudio,
|
|
405
409
|
moveAudioToPending: moveSessionAudioToPending,
|
|
406
410
|
delete: deleteSession,
|
|
411
|
+
getState: defaultSessionState,
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
function countWords(text: string): number {
|
|
@@ -566,10 +571,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
566
571
|
|
|
567
572
|
const transcript = sessions.getTranscript(sessionId)
|
|
568
573
|
if (!transcript?.trim()) {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
574
|
+
// 6.53.3 (review D3): a session the server KNOWS (live, or closed within its
|
|
575
|
+
// retention) that holds no transcript is not one it never heard of. Its own reason
|
|
576
|
+
// lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
|
|
577
|
+
// Still 404, so every existing client reads it exactly as before.
|
|
578
|
+
const known = (sessions.getState ?? defaultSessionState)(sessionId)
|
|
579
|
+
res.status(404).json(known === 'active' || known === 'closed'
|
|
580
|
+
? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
|
|
581
|
+
: { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
|
|
573
582
|
return
|
|
574
583
|
}
|
|
575
584
|
|
|
@@ -437,6 +437,9 @@ export interface TranscriptionSessionLiveness {
|
|
|
437
437
|
*/
|
|
438
438
|
export const __sessionsForTests = sessions
|
|
439
439
|
|
|
440
|
+
/** 6.53.3: the closed-session records, for tests only (same reasoning as above). */
|
|
441
|
+
export const __closedSessionRecordsForTests = closedSessionRecords
|
|
442
|
+
|
|
440
443
|
export interface StrandedCapture {
|
|
441
444
|
sessionId: string
|
|
442
445
|
/** Minutes since the last ACK'd chunk. */
|
|
@@ -1151,6 +1154,8 @@ setInterval(() => {
|
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
} catch {}
|
|
1157
|
+
// 6.53.3 (review D3): the in-memory records age out with the file's.
|
|
1158
|
+
try { sweepExpiredClosedSessions(Date.now()) } catch {}
|
|
1154
1159
|
persistClosedSessions()
|
|
1155
1160
|
}, 60_000)
|
|
1156
1161
|
|
|
@@ -1455,7 +1460,30 @@ export interface MeetingSessionStatusSnapshot {
|
|
|
1455
1460
|
retainedUntil: string | null
|
|
1456
1461
|
}
|
|
1457
1462
|
|
|
1458
|
-
|
|
1463
|
+
/**
|
|
1464
|
+
* 6.53.3 (review D3): forget a closed session once its retention has passed, in memory as
|
|
1465
|
+
* well as on disk. Until 6.53.2 only the persisted file was pruned, so a closed session read
|
|
1466
|
+
* `closed` until the next restart, and a phone whose recovery hint named it POSTed save
|
|
1467
|
+
* (which can only 404) on every boot and foreground, all day. Past `retainedUntil` the
|
|
1468
|
+
* server now answers exactly as it would after a restart: `missing`, which every app since
|
|
1469
|
+
* 6.9.465 settles as terminal ("the server no longer retains it").
|
|
1470
|
+
*/
|
|
1471
|
+
function evictExpiredClosedSession(sessionId: string, now: number): boolean {
|
|
1472
|
+
const closed = closedSessionRecords.get(sessionId)
|
|
1473
|
+
if (!closed || now - closed.closedAt <= CLOSED_SESSION_TTL_MS) return false
|
|
1474
|
+
closedSessionRecords.delete(sessionId)
|
|
1475
|
+
deletedSessions.delete(sessionId)
|
|
1476
|
+
return true
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
export function sweepExpiredClosedSessions(now = Date.now()): number {
|
|
1480
|
+
let evicted = 0
|
|
1481
|
+
for (const id of [...closedSessionRecords.keys()]) if (evictExpiredClosedSession(id, now)) evicted++
|
|
1482
|
+
return evicted
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
export function getMeetingSessionStatus(sessionId: string, now = Date.now()): MeetingSessionStatusSnapshot {
|
|
1486
|
+
evictExpiredClosedSession(sessionId, now)
|
|
1459
1487
|
const active = sessions.get(sessionId)
|
|
1460
1488
|
if (active) {
|
|
1461
1489
|
const received = active.receivedIndices ?? []
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
|
|
9
9
|
// settings, a missing packaged script), 64 on a bad argument. Never prints the token.
|
|
10
10
|
|
|
11
|
-
import { hookStatus, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
|
|
11
|
+
import { hookStatus, hookStatusAdvice, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
|
|
12
12
|
import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
|
|
13
13
|
|
|
14
14
|
const args = process.argv.slice(2)
|
|
@@ -30,7 +30,11 @@ const serverApplies = sessionHooksEnabled()
|
|
|
30
30
|
const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
|
|
31
31
|
|
|
32
32
|
if (action === 'status') {
|
|
33
|
-
|
|
33
|
+
const status = hookStatus()
|
|
34
|
+
// 6.53.3: say in words what the state means and what fixes it (`script_outdated` after an
|
|
35
|
+
// update that changed the script, until Install hooks).
|
|
36
|
+
const advice = hookStatusAdvice(status)
|
|
37
|
+
print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status })
|
|
34
38
|
process.exit(0)
|
|
35
39
|
}
|
|
36
40
|
if (action === 'install') {
|