@gotcos/glasses-server 6.53.1 → 6.53.4
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 +38 -0
- package/README.md +37 -1
- package/bin/hooks/cos-session-hook +88 -43
- package/package.json +1 -1
- package/server/index.ts +32 -61
- package/server/lib/attached-provider-adapter.ts +508 -176
- package/server/lib/claude-hooks-installer.ts +58 -0
- package/server/lib/fence-liveness.ts +76 -2
- package/server/lib/queue-turn-evidence.ts +137 -0
- package/server/lib/session-cancel.ts +70 -12
- package/server/lib/session-halt.ts +141 -0
- package/server/lib/session-hooks-runtime.ts +62 -2
- package/server/routes/agent-session-bindings.ts +265 -28
- 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
|
@@ -284,6 +284,64 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
|
|
|
284
284
|
return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 6.53.3: sha256 of every EARLIER script that already carries the halt check. 6.53.3
|
|
289
|
+
* rewrote the script for speed (review A, option a), so an install that has not run Install
|
|
290
|
+
* hooks since reads `script_outdated` and Control shows its banner. Those scripts still stop
|
|
291
|
+
* a desk run exactly as before, though, so a desk cancel is not refused `hooks_outdated` in
|
|
292
|
+
* the meantime (`hookHaltReady`). A script older than 6.53.0 has no halt check and never
|
|
293
|
+
* qualifies.
|
|
294
|
+
* - 1158bb06...: the one script 6.53.0, 6.53.1 and 6.53.2 all shipped.
|
|
295
|
+
* - c0b41bf8...: the 6.53.3 script as first built, before its /qa round made every event
|
|
296
|
+
* drain stdin. Never published, but installable from a local build of that commit
|
|
297
|
+
* (7433b7f), and it carries the same halt check.
|
|
298
|
+
* Each is pinned against its bytes in `server/lib/__fixtures__/`.
|
|
299
|
+
*/
|
|
300
|
+
export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
|
|
301
|
+
'1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
|
|
302
|
+
'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
|
|
307
|
+
* difference is a halt-capable earlier script (above). What the cancel route's
|
|
308
|
+
* `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
|
|
309
|
+
*/
|
|
310
|
+
export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
|
|
311
|
+
if (status.installed === true) return true
|
|
312
|
+
return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
|
|
313
|
+
&& HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
|
|
320
|
+
* it. Null when installed. Carries no path: the status JSON beside it already names them.
|
|
321
|
+
*/
|
|
322
|
+
export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
|
|
323
|
+
const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
|
|
324
|
+
switch (status.state) {
|
|
325
|
+
case 'installed':
|
|
326
|
+
return null
|
|
327
|
+
case 'script_outdated':
|
|
328
|
+
return `The installed hook script is older than this package's (installed ${short(status.scriptSha)}, package ${short(status.packageScriptSha)}). `
|
|
329
|
+
+ 'Every Claude session keeps running the old script until the hooks are reinstalled: '
|
|
330
|
+
+ `run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
|
|
331
|
+
+ (hookHaltReady(status)
|
|
332
|
+
? 'Cancel from the lens still stops desk runs with the installed script meanwhile.'
|
|
333
|
+
: 'Until then a desk run cannot be cancelled from the lens.')
|
|
334
|
+
case 'drift':
|
|
335
|
+
case 'missing':
|
|
336
|
+
return `The hook subscriptions in the Claude settings file are ${status.state === 'missing' ? 'not installed' : 'not the ones this package installs'}. `
|
|
337
|
+
+ `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control.`
|
|
338
|
+
case 'disabled_by_settings':
|
|
339
|
+
return 'The Claude settings file sets disableAllHooks: no hook runs, installed or not.'
|
|
340
|
+
default:
|
|
341
|
+
return 'The Claude settings file could not be read as it is; nothing was changed. Fix or restore the file, then install again.'
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
287
345
|
export interface InstallOptions {
|
|
288
346
|
settingsPath?: string
|
|
289
347
|
scriptPath?: string
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
// human deciding whether to release; an over-confident `none_running` is worse than
|
|
28
28
|
// admitting the probe could not see.
|
|
29
29
|
|
|
30
|
+
import { execFileSync } from 'node:child_process'
|
|
31
|
+
import { existsSync } from 'node:fs'
|
|
32
|
+
|
|
30
33
|
/** Recorded spawn: a pid and the start COS measured when it created the child. */
|
|
31
34
|
export interface RecordedSpawn {
|
|
32
35
|
pid: number
|
|
@@ -55,7 +58,7 @@ export interface FenceLiveness {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export interface FenceLivenessDeps {
|
|
58
|
-
/** Epoch ms at which `pid` started, or null when it is not running.
|
|
61
|
+
/** Epoch ms at which `pid` started, or null ONLY when it is provably not running.
|
|
59
62
|
* MAY THROW; a throwing probe is not an answer and resolves to `unknown`. */
|
|
60
63
|
pidStartMs: (pid: number) => number | null
|
|
61
64
|
}
|
|
@@ -116,7 +119,9 @@ export function makePidStartProbe(
|
|
|
116
119
|
const raw = run(pid)
|
|
117
120
|
if (raw === null) return null
|
|
118
121
|
const text = raw.trim()
|
|
119
|
-
|
|
122
|
+
// A clean exit that printed nothing is not "not running" (only the runner's null is):
|
|
123
|
+
// throw, so it is counted unverifiable.
|
|
124
|
+
if (!text) throw new Error('empty process start from ps')
|
|
120
125
|
const parsed = Date.parse(text)
|
|
121
126
|
// Unparseable is NOT "not running" -- throw so the caller counts it
|
|
122
127
|
// unverifiable rather than silently reading a live child as gone.
|
|
@@ -124,3 +129,72 @@ export function makePidStartProbe(
|
|
|
124
129
|
return parsed
|
|
125
130
|
}
|
|
126
131
|
}
|
|
132
|
+
|
|
133
|
+
/** What this module reads off the error `execFileSync` throws. */
|
|
134
|
+
export interface ExecFailureLike {
|
|
135
|
+
status?: unknown
|
|
136
|
+
signal?: unknown
|
|
137
|
+
killed?: unknown
|
|
138
|
+
code?: unknown
|
|
139
|
+
error?: unknown
|
|
140
|
+
stdout?: unknown
|
|
141
|
+
stderr?: unknown
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The slice of `execFileSync` the probe calls. Injectable so a test can throw every error
|
|
145
|
+
* shape Node produces without spawning anything. */
|
|
146
|
+
export type PsExec = (file: string, args: readonly string[], options: { encoding: 'utf8'; timeout: number }) => string
|
|
147
|
+
|
|
148
|
+
/** How long one `ps` may take before Node kills it (the kill is NOT an answer). */
|
|
149
|
+
export const PS_PROBE_TIMEOUT_MS = 2_000
|
|
150
|
+
|
|
151
|
+
const PS_BIN = existsSync('/bin/ps') ? '/bin/ps' : 'ps'
|
|
152
|
+
|
|
153
|
+
const emptyOutput = (value: unknown): boolean =>
|
|
154
|
+
(typeof value === 'string' && value.trim() === '') || (Buffer.isBuffer(value) && value.length === 0)
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 6.53.3 /qa BLOCKER: is this `execFileSync` failure `ps -p` saying "no such process"?
|
|
158
|
+
*
|
|
159
|
+
* THE ONE ANSWER THAT MEANS GONE. `ps -p <pid>` for a pid that is not running exits 1 on its
|
|
160
|
+
* own and prints nothing on either stream (measured, macOS 26 and node 24: `{status: 1,
|
|
161
|
+
* signal: null, stdout: '', stderr: ''}` with no `code` and no `error`). Everything else is
|
|
162
|
+
* ps failing to answer, and says nothing about the child: a kill at the timeout (`ETIMEDOUT`,
|
|
163
|
+
* SIGTERM), a fork that failed (`EAGAIN`), no binary (`ENOENT`), a signal, output on stdout or
|
|
164
|
+
* a complaint on stderr, any other status. Until 6.53.3's QA every one of those read as gone,
|
|
165
|
+
* and gone is `none_running`, which releases a cancel fence (`releaseSettledCancelFences`) on a
|
|
166
|
+
* child that may still be writing.
|
|
167
|
+
*/
|
|
168
|
+
export function psSaysNoSuchProcess(error: unknown): boolean {
|
|
169
|
+
if (error === null || typeof error !== 'object') return false
|
|
170
|
+
const e = error as ExecFailureLike
|
|
171
|
+
return e.status === 1
|
|
172
|
+
&& (e.signal === null || e.signal === undefined)
|
|
173
|
+
&& e.killed !== true
|
|
174
|
+
&& (e.code === null || e.code === undefined)
|
|
175
|
+
&& (e.error === null || e.error === undefined)
|
|
176
|
+
&& emptyOutput(e.stdout)
|
|
177
|
+
&& emptyOutput(e.stderr)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The production runner: `ps -p <pid> -o lstart=`'s stdout, or null ONLY when ps said the
|
|
182
|
+
* process does not exist (`psSaysNoSuchProcess`). Any other failure THROWS, which
|
|
183
|
+
* `fenceLiveness` counts unverifiable, so the fence stays.
|
|
184
|
+
*/
|
|
185
|
+
export function psLstartRunner(exec: PsExec = execFileSync as unknown as PsExec): (pid: number) => string | null {
|
|
186
|
+
return (pid: number) => {
|
|
187
|
+
try {
|
|
188
|
+
return exec(PS_BIN, ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: PS_PROBE_TIMEOUT_MS })
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (psSaysNoSuchProcess(error)) return null
|
|
191
|
+
const e = (error ?? {}) as ExecFailureLike
|
|
192
|
+
throw new Error(`ps could not answer for pid ${pid} (status=${String(e.status ?? null)} signal=${String(e.signal ?? null)} code=${String(e.code ?? 'none')})`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The probe the fence routes use in production. */
|
|
198
|
+
export function defaultPidStartProbe(exec?: PsExec): (pid: number) => number | null {
|
|
199
|
+
return makePidStartProbe(psLstartRunner(exec))
|
|
200
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The thread-turn queue's two turn questions, `turnEnded` and `turnOpen` (6.53.3).
|
|
2
|
+
//
|
|
3
|
+
// Moved out of the composition root (index.ts) unchanged in shape, so the one rule that
|
|
4
|
+
// changed in 6.53.3 can be driven by a test against the real `drainDecision`: WHAT a
|
|
5
|
+
// cancel on the thread may conclude about a turn that still reads open depends on what the
|
|
6
|
+
// cancel was aimed at (review A1, `cancelVoidsOpenTurn` in session-cancel.ts).
|
|
7
|
+
//
|
|
8
|
+
// cos_turn COS killed its own child: nothing written since the cancel ends the turn.
|
|
9
|
+
// desk_run the run stops at its NEXT tool call, so the hooks' and the transcript's
|
|
10
|
+
// open-turn evidence stands until the desk's own engine says the run is over
|
|
11
|
+
// at or after the cancel (a Stop, StopFailure or SessionEnd, or the registry's
|
|
12
|
+
// idle flip). Until 6.53.2 a desk run inside a long tool read "over" 30 s after
|
|
13
|
+
// the cancel, and a parked follow-up was delivered live into it.
|
|
14
|
+
//
|
|
15
|
+
// Every read is injected; index.ts wires the real ones. Both answers refuse on doubt: a
|
|
16
|
+
// throw is `false` for each, which is what the composition root did before the move.
|
|
17
|
+
|
|
18
|
+
import { cancelVoidsOpenTurn, type ThreadCancel } from './session-cancel.js'
|
|
19
|
+
|
|
20
|
+
/** The hook signal fields these questions read (a `SessionSignal` satisfies it). */
|
|
21
|
+
export interface TurnEvidenceSignal {
|
|
22
|
+
turnOpen: boolean
|
|
23
|
+
lastEvent: string
|
|
24
|
+
lastEventAt: number
|
|
25
|
+
turnStartedAt: number | null
|
|
26
|
+
stopAt: number | null
|
|
27
|
+
subagentsOpen: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The transcript tail's own verdict (`transcriptTurnVerdict`). */
|
|
31
|
+
export interface TurnEvidenceVerdict {
|
|
32
|
+
ended: boolean
|
|
33
|
+
reason?: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QueueTurnEvidenceDeps {
|
|
37
|
+
/** The hooks' signal for a Claude session; undefined when the hooks are off or silent. */
|
|
38
|
+
signalFor: (threadId: string) => TurnEvidenceSignal | undefined
|
|
39
|
+
/** `registryIdleAfterStop`: true only when the live record flipped idle at or after `stopAt`. */
|
|
40
|
+
registryIdleAfterStop: (threadId: string, stopAt: number) => boolean | null
|
|
41
|
+
/** `deskTurnEndedAt`: when the desk session's engine said its run is over, as it stands now. */
|
|
42
|
+
deskEndedAt: (threadId: string) => number | null
|
|
43
|
+
/** The transcript's mtime, or null when unmeasurable. */
|
|
44
|
+
transcriptMtimeMs: (provider: string, threadId: string) => number | null
|
|
45
|
+
/** The transcript tail's verdict, or null when unreadable. */
|
|
46
|
+
transcriptVerdict: (provider: string, threadId: string) => TurnEvidenceVerdict | null
|
|
47
|
+
/** The cancel on record for the thread (`threadCancel`). */
|
|
48
|
+
threadCancel: (provider: string, threadId: string) => ThreadCancel | null
|
|
49
|
+
/** Past this age a signal or a tail is not evidence any more (OPEN_TURN_CEILING_MS). */
|
|
50
|
+
openTurnCeilingMs: number
|
|
51
|
+
now: () => number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QueueTurnEvidence {
|
|
55
|
+
turnEnded: (provider: string, threadId: string) => boolean
|
|
56
|
+
turnOpen: (provider: string, threadId: string) => boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function makeQueueTurnEvidence(deps: QueueTurnEvidenceDeps): QueueTurnEvidence {
|
|
60
|
+
const mtime = (provider: string, threadId: string): number | null => {
|
|
61
|
+
const value = deps.transcriptMtimeMs(provider, threadId)
|
|
62
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
63
|
+
}
|
|
64
|
+
/** Read once per question, and only when a cancel is on record (it scans the registry). */
|
|
65
|
+
const deskEnded = (provider: string, threadId: string): (() => number | null) => {
|
|
66
|
+
let cached: number | null | undefined
|
|
67
|
+
return () => {
|
|
68
|
+
if (cached === undefined) cached = provider === 'claude' ? deps.deskEndedAt(threadId) : null
|
|
69
|
+
return cached
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const turnEnded = (provider: string, threadId: string): boolean => {
|
|
74
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
75
|
+
try {
|
|
76
|
+
const signal = provider === 'claude' ? deps.signalFor(threadId) : undefined
|
|
77
|
+
// 6.53.0: a cancel ends the turn when nothing was written since it (a killed COS
|
|
78
|
+
// child leaves its last tool_use unanswered, and no Stop or terminal record follows).
|
|
79
|
+
// 6.53.3: only a cos_turn cancel concludes that; a desk_run cancel needs the desk's
|
|
80
|
+
// own end, stamped at or after the cancel.
|
|
81
|
+
const cancel = deps.threadCancel(provider, threadId)
|
|
82
|
+
if (cancel !== null && cancelVoidsOpenTurn({
|
|
83
|
+
cancel,
|
|
84
|
+
lastActivityAt: mtime(provider, threadId),
|
|
85
|
+
turnStartedAt: signal?.turnStartedAt ?? null,
|
|
86
|
+
deskEndedAt: deskEnded(provider, threadId)(),
|
|
87
|
+
})) return true
|
|
88
|
+
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
89
|
+
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
90
|
+
// typed during them is queued and dequeues only then). The transcript tail
|
|
91
|
+
// answers for everything the hooks did not see. Both refuse on doubt.
|
|
92
|
+
if (provider === 'claude') {
|
|
93
|
+
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
94
|
+
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
95
|
+
&& deps.registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
96
|
+
}
|
|
97
|
+
return deps.transcriptVerdict(provider, threadId)?.ended === true
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
104
|
+
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
105
|
+
// signal or a tail older than the ceiling is not evidence any more, so a crashed
|
|
106
|
+
// mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
107
|
+
const turnOpen = (provider: string, threadId: string): boolean => {
|
|
108
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
109
|
+
try {
|
|
110
|
+
const now = deps.now()
|
|
111
|
+
const cancel = deps.threadCancel(provider, threadId)
|
|
112
|
+
const ended = deskEnded(provider, threadId)
|
|
113
|
+
if (provider === 'claude') {
|
|
114
|
+
const signal = deps.signalFor(threadId)
|
|
115
|
+
if (signal && signal.turnOpen && now - signal.lastEventAt <= deps.openTurnCeilingMs
|
|
116
|
+
&& !(cancel !== null && cancelVoidsOpenTurn({
|
|
117
|
+
cancel,
|
|
118
|
+
lastActivityAt: signal.lastEventAt,
|
|
119
|
+
turnStartedAt: signal.turnStartedAt,
|
|
120
|
+
deskEndedAt: ended(),
|
|
121
|
+
}))) return true
|
|
122
|
+
}
|
|
123
|
+
const verdict = deps.transcriptVerdict(provider, threadId)
|
|
124
|
+
if (!verdict || verdict.ended) return false
|
|
125
|
+
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
126
|
+
const last = mtime(provider, threadId)
|
|
127
|
+
// 6.53.0 / 6.53.3: a cos_turn cancel with nothing written since discounts a dangling
|
|
128
|
+
// tool_use (the killed child's); a desk_run cancel only once the desk said it ended.
|
|
129
|
+
if (cancel !== null && cancelVoidsOpenTurn({ cancel, lastActivityAt: last, turnStartedAt: null, deskEndedAt: ended() })) return false
|
|
130
|
+
return last !== null && now - last <= deps.openTurnCeilingMs
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { turnEnded, turnOpen }
|
|
137
|
+
}
|
|
@@ -104,30 +104,47 @@ export const CANCEL_QUEUE_HOLD_MS = 2 * 60_000
|
|
|
104
104
|
export const CANCEL_MEMORY_MS = 60 * 60_000
|
|
105
105
|
const MAX_REMEMBERED_CANCELS = 256
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* One cancel on record for a thread (6.53.3): WHEN, and WHAT it was aimed at. The target
|
|
109
|
+
* decides what the cancel may conclude about a turn that still reads open (see
|
|
110
|
+
* `cancelVoidsOpenTurn`). Null is an older caller that did not say; it is read as a desk
|
|
111
|
+
* run, the reading that concludes the least.
|
|
112
|
+
*/
|
|
113
|
+
export interface ThreadCancel {
|
|
114
|
+
at: number
|
|
115
|
+
target: CancelTarget | null
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
// In memory on purpose: every reader is a hold of minutes, and a restart that forgets
|
|
108
119
|
// one lets the queue drain by the gate's ordinary rules, which is the pre-6.53 behaviour.
|
|
109
|
-
const
|
|
120
|
+
const cancelsByThread = new Map<string, ThreadCancel>()
|
|
110
121
|
const cancelKey = (provider: string, threadId: string) => `${provider}:${threadId.toLowerCase()}`
|
|
111
122
|
|
|
112
|
-
export function noteThreadCancelled(provider: string, threadId: string, at: number): void {
|
|
123
|
+
export function noteThreadCancelled(provider: string, threadId: string, at: number, target: CancelTarget | null = null): void {
|
|
113
124
|
if (!Number.isFinite(at)) return
|
|
114
125
|
const key = cancelKey(provider, threadId)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
for (const [k, when] of
|
|
119
|
-
if (at - when > CANCEL_MEMORY_MS)
|
|
126
|
+
cancelsByThread.delete(key)
|
|
127
|
+
cancelsByThread.set(key, { at, target })
|
|
128
|
+
if (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
129
|
+
for (const [k, when] of cancelsByThread) {
|
|
130
|
+
if (at - when.at > CANCEL_MEMORY_MS) cancelsByThread.delete(k)
|
|
120
131
|
}
|
|
121
|
-
while (
|
|
122
|
-
const oldest =
|
|
132
|
+
while (cancelsByThread.size > MAX_REMEMBERED_CANCELS) {
|
|
133
|
+
const oldest = cancelsByThread.keys().next()
|
|
123
134
|
if (oldest.done) break
|
|
124
|
-
|
|
135
|
+
cancelsByThread.delete(oldest.value)
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
export function threadCancelledAt(provider: string, threadId: string): number | null {
|
|
130
|
-
return
|
|
141
|
+
return cancelsByThread.get(cancelKey(provider, threadId))?.at ?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 6.53.3: the cancel on record for the thread, with its target; null when none. A copy. */
|
|
145
|
+
export function threadCancel(provider: string, threadId: string): ThreadCancel | null {
|
|
146
|
+
const found = cancelsByThread.get(cancelKey(provider, threadId))
|
|
147
|
+
return found ? { at: found.at, target: found.target } : null
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/** Until when the thread's parked turns hold, or null when no cancel is on record. */
|
|
@@ -151,8 +168,49 @@ export function cancelEndsTurn(cancelledAt: number | null, lastRowAt: number | n
|
|
|
151
168
|
return cancelledAt >= lastRowAt
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
/** What `cancelVoidsOpenTurn` weighs for one piece of open-turn evidence. */
|
|
172
|
+
export interface OpenTurnEvidence {
|
|
173
|
+
/** The cancel on record for the thread (`threadCancel`), or null. */
|
|
174
|
+
cancel: ThreadCancel | null
|
|
175
|
+
/** When the evidence was last written: the hook's newest event, or the transcript mtime. */
|
|
176
|
+
lastActivityAt: number | null
|
|
177
|
+
/** When the turn the evidence belongs to STARTED (the hook's UserPromptSubmit); null when unknown. */
|
|
178
|
+
turnStartedAt: number | null
|
|
179
|
+
/**
|
|
180
|
+
* When the desk session's own engine said the run is over, as it stands NOW: the
|
|
181
|
+
* registry's `idle` flip (`statusUpdatedAt`), or the hooks' Stop / StopFailure /
|
|
182
|
+
* SessionEnd while no turn is open. Null when nothing says so.
|
|
183
|
+
*/
|
|
184
|
+
deskEndedAt: number | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 6.53.3 (review A1): may the cancel on record discount evidence that the thread's turn
|
|
189
|
+
* is still OPEN?
|
|
190
|
+
*
|
|
191
|
+
* cos_turn COS killed its own child. Nothing written since the cancel means the turn
|
|
192
|
+
* is over: the dangling `tool_use` is the dead child's (6.53.0, `cancelEndsTurn`).
|
|
193
|
+
* desk_run A halt marker stops a desk run only at its NEXT tool call, so a run inside a
|
|
194
|
+
* long tool writes nothing for minutes and is still running. Silence proves
|
|
195
|
+
* nothing here. Only the engine's own end, stamped at or after the cancel and
|
|
196
|
+
* after the turn began, closes it. Until 6.53.2 the cos_turn rule applied to
|
|
197
|
+
* both, and a turn parked behind a cancelled desk run was delivered live into
|
|
198
|
+
* the run the person had just stopped (and its UserPromptSubmit then deleted
|
|
199
|
+
* the marker, so the cancel was lost).
|
|
200
|
+
* null An older caller that did not say: read as desk_run, which concludes least.
|
|
201
|
+
*/
|
|
202
|
+
export function cancelVoidsOpenTurn(evidence: OpenTurnEvidence): boolean {
|
|
203
|
+
const { cancel } = evidence
|
|
204
|
+
if (cancel === null || !Number.isFinite(cancel.at)) return false
|
|
205
|
+
if (cancel.target === 'cos_turn') return cancelEndsTurn(cancel.at, evidence.lastActivityAt)
|
|
206
|
+
const ended = evidence.deskEndedAt
|
|
207
|
+
if (ended === null || !Number.isFinite(ended) || ended < cancel.at) return false
|
|
208
|
+
const started = evidence.turnStartedAt
|
|
209
|
+
return started === null || !Number.isFinite(started) || ended >= started
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
export function __resetThreadCancelsForTests(): void {
|
|
155
|
-
|
|
213
|
+
cancelsByThread.clear()
|
|
156
214
|
}
|
|
157
215
|
|
|
158
216
|
// ---------------------------------------------------------------------------
|
|
@@ -28,12 +28,17 @@
|
|
|
28
28
|
// - a sweep deletes anything older than HALT_MARKER_TTL_MS.
|
|
29
29
|
// It is NOT deleted on Stop: a background subagent reports its parent's session id (C8),
|
|
30
30
|
// and it is exactly the work a cancel must also stop.
|
|
31
|
+
//
|
|
32
|
+
// 6.53.3: ONE exception to "a new prompt clears it": a turn COS itself handed into the open
|
|
33
|
+
// session while a cancel was on its way (`haltDeliveredTurn`). That turn's own prompt must
|
|
34
|
+
// not be the one that frees it, so the server writes the marker back, once, when it sees it.
|
|
31
35
|
|
|
32
36
|
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
33
37
|
import { join } from 'node:path'
|
|
34
38
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
35
39
|
import { dataPath } from './data-dir.js'
|
|
36
40
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
41
|
+
import { unwrapPeerMessage } from './session-stream-events.js'
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* How long a marker may outlive its cancel. An hour, not the plan's first 15 minutes: the
|
|
@@ -42,6 +47,14 @@ import { isValidNativeThreadId } from './native-thread-id.js'
|
|
|
42
47
|
*/
|
|
43
48
|
export const HALT_MARKER_TTL_MS = 60 * 60_000
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 6.53.3 /qa: how long a re-arm waits when the hand-off was NOT verified (`maybe`: the frame
|
|
52
|
+
* may or may not have reached the session). A verified hand-off keeps the marker's hour; an
|
|
53
|
+
* unverified one that has not surfaced as a prompt within two minutes most likely never
|
|
54
|
+
* arrived, and holding it longer only widens the window for an identical desk prompt.
|
|
55
|
+
*/
|
|
56
|
+
export const HALT_REARM_UNVERIFIED_MS = 2 * 60_000
|
|
57
|
+
|
|
45
58
|
export interface HaltMarker {
|
|
46
59
|
at: number
|
|
47
60
|
clientCancelId: string
|
|
@@ -105,6 +118,134 @@ export function clearHaltMarkerOnSessionEnd(sessionId: string, endedAt: number,
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// 6.53.3 (review A6): a cancel that landed while COS was HANDING a turn to the open session
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
//
|
|
125
|
+
// The live hop writes the turn into the running session's inbox, where it waits its turn
|
|
126
|
+
// (a busy session) or starts at once (an idle one). Once it has landed, the only stop is
|
|
127
|
+
// this marker. But the hook deletes the marker on UserPromptSubmit, and the delivered
|
|
128
|
+
// turn's OWN UserPromptSubmit may come after the marker is written: a busy session takes
|
|
129
|
+
// the message only when its current run ends (which the marker itself brings about). A
|
|
130
|
+
// plain write would then be deleted by the very turn it was written to stop.
|
|
131
|
+
//
|
|
132
|
+
// So the marker is written, and when the delivered turn has not visibly started yet, it is
|
|
133
|
+
// written AGAIN, once, on the first UserPromptSubmit for that session AFTER the send, if that
|
|
134
|
+
// prompt IS the delivered one. The window is the marker's own hour (two minutes for an
|
|
135
|
+
// unverified hand-off); SessionEnd drops it. In memory on purpose: a restart forgets it, like
|
|
136
|
+
// every other hold here.
|
|
137
|
+
//
|
|
138
|
+
// 6.53.3 /qa (Skeptic W1): the first cut matched `prompt.includes(<first 120 characters>)`.
|
|
139
|
+
// A short COS prompt ("yes") is inside most desk prompts, so the person's own next prompt
|
|
140
|
+
// re-armed the marker: THEIR run was denied "Cancelled from COS", and the delivered turn's
|
|
141
|
+
// own prompt, finding nothing pending, then ran the cancelled turn. Now:
|
|
142
|
+
// - EXACT: the delivered words and the prompt's words are equal once whitespace runs are
|
|
143
|
+
// collapsed and Claude Code's peer wrapper ("Another Claude session sent a message: ...",
|
|
144
|
+
// which a live Continue arrives in) is taken off. The frame carries no id of its own and
|
|
145
|
+
// its text is not changed to add one.
|
|
146
|
+
// - FIRST PROMPT DECIDES: the first prompt after the send spends the re-arm whether or not
|
|
147
|
+
// it matches. A non-match is logged (never the prompt). This gives up the cancel when
|
|
148
|
+
// the person's own prompt reaches the session first, rather than risk halting their run:
|
|
149
|
+
// a halted desk run is the worse error, and the lens already said "at its next step".
|
|
150
|
+
// - LIMIT, stated: a desk prompt with exactly the delivered words, typed after the send and
|
|
151
|
+
// taken first, is indistinguishable from the delivered turn and re-arms.
|
|
152
|
+
|
|
153
|
+
interface PendingRearm {
|
|
154
|
+
marker: HaltMarker
|
|
155
|
+
/** The delivered prompt's words, whitespace-normalised (`normalizePromptWords`). */
|
|
156
|
+
words: string
|
|
157
|
+
/** Prompts submitted before this are someone else's. */
|
|
158
|
+
after: number
|
|
159
|
+
until: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pendingRearms = new Map<string, PendingRearm>()
|
|
163
|
+
const MAX_PENDING_REARMS = 64
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Write the marker for a turn COS just put into the open session. `rearm` is false when
|
|
167
|
+
* the turn has visibly started already (its UserPromptSubmit ran before this write, so
|
|
168
|
+
* nothing will delete the marker but the next prompt). False when the write failed; the
|
|
169
|
+
* caller must then not report the cancel as armed.
|
|
170
|
+
*/
|
|
171
|
+
export function haltDeliveredTurn(
|
|
172
|
+
sessionId: string,
|
|
173
|
+
marker: HaltMarker,
|
|
174
|
+
turn: { prompt: string; after: number; rearm: boolean; now?: number; windowMs?: number },
|
|
175
|
+
dir = haltDir(),
|
|
176
|
+
): boolean {
|
|
177
|
+
if (!writeHaltMarker(sessionId, marker, dir)) return false
|
|
178
|
+
const id = sessionId.toLowerCase()
|
|
179
|
+
const words = normalizePromptWords(turn.prompt)
|
|
180
|
+
if (!turn.rearm || words.length === 0) {
|
|
181
|
+
pendingRearms.delete(id)
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
const now = turn.now ?? Date.now()
|
|
185
|
+
const windowMs = typeof turn.windowMs === 'number' && Number.isFinite(turn.windowMs) && turn.windowMs > 0
|
|
186
|
+
? Math.min(turn.windowMs, HALT_MARKER_TTL_MS)
|
|
187
|
+
: HALT_MARKER_TTL_MS
|
|
188
|
+
pendingRearms.delete(id)
|
|
189
|
+
pendingRearms.set(id, { marker, words, after: turn.after, until: now + windowMs })
|
|
190
|
+
while (pendingRearms.size > MAX_PENDING_REARMS) {
|
|
191
|
+
const oldest = pendingRearms.keys().next()
|
|
192
|
+
if (oldest.done) break
|
|
193
|
+
pendingRearms.delete(oldest.value)
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Whitespace runs collapsed to one space, ends trimmed. Non-strings are empty. */
|
|
199
|
+
export function normalizePromptWords(text: unknown): string {
|
|
200
|
+
return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Is `prompt` (a UserPromptSubmit's `prompt`) the delivered prompt? Equal words, compared
|
|
205
|
+
* whole, after taking off Claude Code's peer wrapper when the prompt arrived in one.
|
|
206
|
+
*/
|
|
207
|
+
export function isDeliveredPrompt(prompt: unknown, delivered: string): boolean {
|
|
208
|
+
const want = normalizePromptWords(delivered)
|
|
209
|
+
if (want.length === 0 || typeof prompt !== 'string') return false
|
|
210
|
+
return normalizePromptWords(unwrapPeerMessage(prompt) ?? prompt) === want
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A UserPromptSubmit from the session itself (never a COS child): the hook has just deleted
|
|
215
|
+
* the session's marker. The FIRST such prompt after the send spends the pending re-arm: when
|
|
216
|
+
* it is the delivered turn's own prompt the marker is written back, otherwise the re-arm is
|
|
217
|
+
* dropped and logged. True when the marker was re-armed.
|
|
218
|
+
*/
|
|
219
|
+
export function rearmHaltOnPrompt(sessionId: string, promptAt: number, prompt: unknown, dir = haltDir(), now = Date.now()): boolean {
|
|
220
|
+
const id = typeof sessionId === 'string' ? sessionId.toLowerCase() : ''
|
|
221
|
+
const pending = pendingRearms.get(id)
|
|
222
|
+
if (!pending) return false
|
|
223
|
+
if (now > pending.until) {
|
|
224
|
+
pendingRearms.delete(id)
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isFinite(promptAt) || promptAt < pending.after) return false
|
|
228
|
+
pendingRearms.delete(id)
|
|
229
|
+
if (!isDeliveredPrompt(prompt, pending.words)) {
|
|
230
|
+
console.warn(`[session-halt] re-arm dropped: the first prompt after the hand-off was not the delivered turn session=${id.slice(0, 8)} clientCancelId=${pending.marker.clientCancelId}`)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
return writeHaltMarker(id, pending.marker, dir)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The session ended: nothing is left to stop. */
|
|
237
|
+
export function dropHaltRearm(sessionId: string): void {
|
|
238
|
+
if (typeof sessionId === 'string') pendingRearms.delete(sessionId.toLowerCase())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function hasPendingHaltRearm(sessionId: string): boolean {
|
|
242
|
+
return typeof sessionId === 'string' && pendingRearms.has(sessionId.toLowerCase())
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function __resetHaltRearmsForTests(): void {
|
|
246
|
+
pendingRearms.clear()
|
|
247
|
+
}
|
|
248
|
+
|
|
108
249
|
export function hasHaltMarker(sessionId: string, dir = haltDir()): boolean {
|
|
109
250
|
const path = haltMarkerPath(sessionId, dir)
|
|
110
251
|
if (path === null) return false
|