@gotcos/glasses-server 6.47.0 → 6.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +35 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +78 -6
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +44 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +205 -0
- package/server/lib/session-hook-ledger.ts +133 -0
- package/server/lib/session-hook-spool.ts +272 -0
- package/server/lib/session-hooks-runtime.ts +338 -0
- package/server/lib/session-signal-store.ts +389 -0
- package/server/lib/session-state-derive.ts +249 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +88 -9
- package/server/routes/claude-sessions.ts +42 -21
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +80 -0
- package/server/routes/thread-turn-queue.ts +15 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -21,16 +21,92 @@
|
|
|
21
21
|
//
|
|
22
22
|
// So: same transport, own keyspace. This module is deliberately small.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// A BOUNDED REPLAY RING PER STREAMED SESSION (6.48.1), and why the earlier "no ring"
|
|
25
|
+
// decision moved. `seq` stays per connection, so a shipped client still sees its gaps;
|
|
26
|
+
// a new client ALSO reads `cursor`, monotonic per session for this server's life, and
|
|
27
|
+
// reconnects with `?after=<cursor>` to be handed what it missed instead of a fresh
|
|
28
|
+
// seed. The ring holds the last RING_MAX published events, keeps the ACTIVE TURN whole
|
|
29
|
+
// (from its newest `prompt` draft onward, up to RING_HARD_MAX) and is dropped with the
|
|
30
|
+
// session's last subscriber plus a linger, so memory is bounded by streamed sessions,
|
|
31
|
+
// not by sessions ever opened. Even Terminal 0.10.4 ships the same shape (500-event
|
|
32
|
+
// ring, replay on reconnect, the active turn always retained).
|
|
29
33
|
|
|
30
34
|
import type { SessionStreamDraft } from './session-stream-events.js'
|
|
31
35
|
|
|
32
|
-
/** A draft with the publish instant stamped. `seq` stays per connection
|
|
33
|
-
export type PublishedSessionEvent = SessionStreamDraft & { at: number }
|
|
36
|
+
/** A draft with the publish instant stamped. `seq` stays per connection; `cursor` is per session within `epoch`. */
|
|
37
|
+
export type PublishedSessionEvent = SessionStreamDraft & { at: number; cursor?: number; epoch?: number }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cursors are meaningful only within one server life: a client holding a cursor from
|
|
41
|
+
* before a restart would otherwise be handed the wrong events from a fresh ring with no
|
|
42
|
+
* gap to notice (QA, 2026-09-15). Every event carries the epoch, and the SSE `id:` line
|
|
43
|
+
* is `<epoch>.<cursor>`; the route refuses to replay across epochs.
|
|
44
|
+
*/
|
|
45
|
+
export const RING_EPOCH = Date.now()
|
|
46
|
+
|
|
47
|
+
/** Events the ring keeps per session. */
|
|
48
|
+
export const RING_MAX = 500
|
|
49
|
+
/** The active turn is never cut short of this. */
|
|
50
|
+
export const RING_HARD_MAX = 2_000
|
|
51
|
+
/** A session's ring outlives its last subscriber this long, so a reconnect finds the gap. */
|
|
52
|
+
export const RING_LINGER_MS = 5 * 60_000
|
|
53
|
+
|
|
54
|
+
interface Ring {
|
|
55
|
+
events: Array<PublishedSessionEvent & { cursor: number }>
|
|
56
|
+
nextCursor: number
|
|
57
|
+
/** Index into `events` of the newest prompt draft, or -1. */
|
|
58
|
+
turnStart: number
|
|
59
|
+
emptySince: number | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rings = new Map<string, Ring>()
|
|
63
|
+
|
|
64
|
+
function ringFor(key: string): Ring {
|
|
65
|
+
let ring = rings.get(key)
|
|
66
|
+
if (!ring) {
|
|
67
|
+
ring = { events: [], nextCursor: 1, turnStart: -1, emptySince: null }
|
|
68
|
+
rings.set(key, ring)
|
|
69
|
+
}
|
|
70
|
+
return ring
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function remember(key: string, event: PublishedSessionEvent): PublishedSessionEvent & { cursor: number } {
|
|
74
|
+
const ring = ringFor(key)
|
|
75
|
+
const stamped = { ...event, cursor: ring.nextCursor++, epoch: RING_EPOCH }
|
|
76
|
+
ring.events.push(stamped)
|
|
77
|
+
if (stamped.kind === 'prompt') ring.turnStart = ring.events.length - 1
|
|
78
|
+
// Trim the oldest, but never into the active turn until the hard cap.
|
|
79
|
+
while (ring.events.length > RING_MAX) {
|
|
80
|
+
if (ring.turnStart === 0 && ring.events.length <= RING_HARD_MAX) break
|
|
81
|
+
ring.events.shift()
|
|
82
|
+
if (ring.turnStart >= 0) ring.turnStart--
|
|
83
|
+
}
|
|
84
|
+
return stamped
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Events published to this session after `afterCursor`, oldest first. Empty for an unknown session. */
|
|
88
|
+
export function replaySessionStream(key: string, afterCursor: number): Array<PublishedSessionEvent & { cursor: number }> {
|
|
89
|
+
const ring = rings.get(key)
|
|
90
|
+
if (!ring) return []
|
|
91
|
+
const after = Number.isFinite(afterCursor) ? afterCursor : 0
|
|
92
|
+
return ring.events.filter(e => e.cursor > after)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The ring's oldest and newest cursors, so a client can tell a replay from a reseed. */
|
|
96
|
+
export function ringBounds(key: string): { oldest: number; newest: number } | null {
|
|
97
|
+
const ring = rings.get(key)
|
|
98
|
+
if (!ring || ring.events.length === 0) return null
|
|
99
|
+
return { oldest: ring.events[0].cursor, newest: ring.events[ring.events.length - 1].cursor }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drop rings whose session has had no subscriber for the linger. Called by the route on release. */
|
|
103
|
+
export function sweepSessionRings(nowMs = Date.now()): number {
|
|
104
|
+
let dropped = 0
|
|
105
|
+
for (const [key, ring] of rings) {
|
|
106
|
+
if (ring.emptySince !== null && nowMs - ring.emptySince > RING_LINGER_MS) { rings.delete(key); dropped++ }
|
|
107
|
+
}
|
|
108
|
+
return dropped
|
|
109
|
+
}
|
|
34
110
|
|
|
35
111
|
export type SessionStreamListener = (event: PublishedSessionEvent) => void
|
|
36
112
|
|
|
@@ -78,6 +154,7 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
78
154
|
const set = existing ?? new Set<SessionStreamListener>()
|
|
79
155
|
set.add(listener)
|
|
80
156
|
listeners.set(key, set)
|
|
157
|
+
ringFor(key).emptySince = null
|
|
81
158
|
|
|
82
159
|
let released = false
|
|
83
160
|
return () => {
|
|
@@ -88,7 +165,12 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
88
165
|
const current = listeners.get(key)
|
|
89
166
|
if (!current) return
|
|
90
167
|
current.delete(listener)
|
|
91
|
-
if (current.size === 0)
|
|
168
|
+
if (current.size === 0) {
|
|
169
|
+
listeners.delete(key)
|
|
170
|
+
const ring = rings.get(key)
|
|
171
|
+
if (ring) ring.emptySince = Date.now()
|
|
172
|
+
sweepSessionRings()
|
|
173
|
+
}
|
|
92
174
|
}
|
|
93
175
|
}
|
|
94
176
|
|
|
@@ -103,7 +185,8 @@ export function subscribeSessionStream(key: string, listener: SessionStreamListe
|
|
|
103
185
|
export function publishSessionStream(key: string, draft: SessionStreamDraft, at: number = Date.now()): number {
|
|
104
186
|
const set = listeners.get(key)
|
|
105
187
|
if (!set || set.size === 0) return 0
|
|
106
|
-
|
|
188
|
+
// Remembered BEFORE fan-out, so a listener that reads the ring sees this event too.
|
|
189
|
+
const event: PublishedSessionEvent = remember(key, { ...draft, at })
|
|
107
190
|
let delivered = 0
|
|
108
191
|
for (const listener of [...set]) {
|
|
109
192
|
try {
|
|
@@ -147,4 +230,5 @@ export function isAttachedTurnActive(key: string): boolean {
|
|
|
147
230
|
export function __resetSessionStreamBusForTests(): void {
|
|
148
231
|
listeners.clear()
|
|
149
232
|
attachedTurns.clear()
|
|
233
|
+
rings.clear()
|
|
150
234
|
}
|
|
@@ -457,6 +457,150 @@ export function draftsFromLine(provider: SessionStreamProvider, line: string): S
|
|
|
457
457
|
return draftsFromRecord(provider, parsed)
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Did the newest turn in this transcript tail END? (6.48.1)
|
|
462
|
+
*
|
|
463
|
+
* THE RULE THE HARNESS USES, read against real Desktop transcripts on this Mac
|
|
464
|
+
* (2026-09-15): a Desktop session writes no `type:'result'` row at all (0 of 30 newest
|
|
465
|
+
* transcripts), so the status-draft rule above can only ever say "ended" for a
|
|
466
|
+
* `claude -p` run. What every Claude transcript DOES carry is `message.stop_reason` on
|
|
467
|
+
* each assistant record: `tool_use` while tools run, `end_turn` when the reply is done.
|
|
468
|
+
*
|
|
469
|
+
* ended the newest assistant record's stop_reason is terminal (end_turn,
|
|
470
|
+
* stop_sequence, max_tokens, refusal) and no tool_use it issued is still
|
|
471
|
+
* waiting for its tool_result; or a `result` row; or the user interrupted
|
|
472
|
+
* (`[Request interrupted by user`), which ends the turn without a reply.
|
|
473
|
+
* open a user prompt newer than the last terminal reply (a `<task-notification>`
|
|
474
|
+
* included: the model answers it), or a tool_use with no result yet.
|
|
475
|
+
*
|
|
476
|
+
* Skipped: `isMeta` and `isCompactSummary` rows (injected context, not a prompt), tool
|
|
477
|
+
* results, and the bookkeeping rows (`system`, `last-prompt`, `attachment`, `mode`...).
|
|
478
|
+
* Codex keeps its event rule (task_complete / turn_complete end, task_started opens).
|
|
479
|
+
*
|
|
480
|
+
* PURE, like everything else here. The queue store reads the tail; this decides.
|
|
481
|
+
*/
|
|
482
|
+
export const TERMINAL_STOP_REASONS: ReadonlySet<string> = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal'])
|
|
483
|
+
|
|
484
|
+
export type TurnFromTail = { ended: boolean; reason: 'terminal_stop' | 'result_row' | 'interrupted' | 'codex_complete' | 'prompt_open' | 'prompt_queued' | 'tool_pending' | 'no_evidence' }
|
|
485
|
+
|
|
486
|
+
export function turnFromTail(provider: SessionStreamProvider, lines: readonly string[]): TurnFromTail {
|
|
487
|
+
let verdict: TurnFromTail = { ended: false, reason: 'no_evidence' }
|
|
488
|
+
const pendingToolUses = new Set<string>()
|
|
489
|
+
for (const line of lines) {
|
|
490
|
+
const trimmed = typeof line === 'string' ? line.trim() : ''
|
|
491
|
+
if (!trimmed || trimmed[0] !== '{') continue
|
|
492
|
+
let record: unknown
|
|
493
|
+
try { record = JSON.parse(trimmed) } catch { continue }
|
|
494
|
+
const r = asRecord(record)
|
|
495
|
+
if (!r) continue
|
|
496
|
+
|
|
497
|
+
if (provider === 'codex') {
|
|
498
|
+
for (const draft of draftsFromRecord('codex', r)) {
|
|
499
|
+
if (draft.kind === 'status') verdict = draft.state === 'done' ? { ended: true, reason: 'codex_complete' } : { ended: false, reason: 'prompt_open' }
|
|
500
|
+
}
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const type = typeof r.type === 'string' ? r.type : ''
|
|
505
|
+
if (type === 'result') { verdict = { ended: true, reason: 'result_row' }; pendingToolUses.clear(); continue }
|
|
506
|
+
// A prompt typed while the Stop hooks run is QUEUED, not submitted (Claude writes a
|
|
507
|
+
// `queue-operation` row); it dequeues once the hooks return and the model answers it.
|
|
508
|
+
// The turn is not over until then (QA, 2026-09-15). The dequeue is followed by the
|
|
509
|
+
// user row itself, which the next branch reads.
|
|
510
|
+
if (type === 'queue-operation') {
|
|
511
|
+
if (r.operation === 'enqueue') verdict = { ended: false, reason: 'prompt_queued' }
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
// Injected context is not a prompt, except a cross-session message (`promptSource:
|
|
515
|
+
// 'system'`), which the model answers as a turn.
|
|
516
|
+
if (r.isCompactSummary === true) continue
|
|
517
|
+
if (r.isMeta === true && r.promptSource !== 'system') continue
|
|
518
|
+
const message = asRecord(r.message)
|
|
519
|
+
if (!message) continue
|
|
520
|
+
const content = Array.isArray(message.content) ? message.content : typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : []
|
|
521
|
+
const blocks = content.map(asRecord).filter((b): b is Record<string, unknown> => b !== null)
|
|
522
|
+
|
|
523
|
+
if (type === 'user' || message.role === 'user') {
|
|
524
|
+
const results = blocks.filter(b => b.type === 'tool_result')
|
|
525
|
+
if (results.length > 0) {
|
|
526
|
+
for (const b of results) if (typeof b.tool_use_id === 'string') pendingToolUses.delete(b.tool_use_id)
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const text = blocks.filter(b => b.type === 'text' || b.type === undefined).map(b => (typeof b.text === 'string' ? b.text : '')).join(' ')
|
|
530
|
+
if (/\[Request interrupted by user/.test(text)) { verdict = { ended: true, reason: 'interrupted' }; pendingToolUses.clear(); continue }
|
|
531
|
+
if (text.trim().length === 0) continue
|
|
532
|
+
verdict = { ended: false, reason: 'prompt_open' }
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (type === 'assistant' || message.role === 'assistant') {
|
|
537
|
+
for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') pendingToolUses.add(b.id)
|
|
538
|
+
const stop = typeof message.stop_reason === 'string' ? message.stop_reason : ''
|
|
539
|
+
if (TERMINAL_STOP_REASONS.has(stop)) {
|
|
540
|
+
verdict = pendingToolUses.size === 0 ? { ended: true, reason: 'terminal_stop' } : { ended: false, reason: 'tool_pending' }
|
|
541
|
+
} else if (stop === 'tool_use' || pendingToolUses.size > 0) {
|
|
542
|
+
verdict = { ended: false, reason: 'tool_pending' }
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return verdict
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The derived session state on a `status` draft (6.48.1).
|
|
551
|
+
*
|
|
552
|
+
* EXTRA FIELDS ON THE EXISTING KIND, NEVER A NEW KIND: every shipped EHPK drops an
|
|
553
|
+
* unknown `kind` before its `seq` accounting and paints a "missed N" gap per event
|
|
554
|
+
* (validation round 1, B2). The shipped parser keeps `seq` and strips unknown fields
|
|
555
|
+
* on a `status` draft (canary 8), so an old client renders exactly as before and a
|
|
556
|
+
* new one reads the state line off the same event.
|
|
557
|
+
*
|
|
558
|
+
* `state` keeps its closed vocabulary. The table:
|
|
559
|
+
* running -> working waiting -> working (+ waiting_kind/detail)
|
|
560
|
+
* idle -> idle failed -> idle (+ failure) ended -> done
|
|
561
|
+
*
|
|
562
|
+
* `done` on open flips the client to its digest body, which is intended for a session
|
|
563
|
+
* whose engine said SessionEnd. An ATTACHED COS TURN WINS (QA, 2026-09-15): the deriver
|
|
564
|
+
* is told `attachedTurn` and answers `running` for as long as the child is writing, so
|
|
565
|
+
* a Continue's trail is never handed off mid-stream by the tab's older Stop or by a
|
|
566
|
+
* child's own SessionEnd; the engine's state resumes the moment the turn detaches.
|
|
567
|
+
*
|
|
568
|
+
* `last_reply` rides only an idle state (the feed's "Idle, last reply: …" line).
|
|
569
|
+
*/
|
|
570
|
+
export interface DerivedStatusFields {
|
|
571
|
+
agent_state: 'running' | 'waiting' | 'idle' | 'failed' | 'ended'
|
|
572
|
+
state_source: 'hook' | 'registry' | 'transcript'
|
|
573
|
+
state_since: string
|
|
574
|
+
waiting_kind?: string
|
|
575
|
+
waiting_detail?: string
|
|
576
|
+
failure?: string
|
|
577
|
+
last_reply?: string
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export type StatusDraftWithDerived = { kind: 'status'; state: SessionStreamState } & Partial<DerivedStatusFields>
|
|
581
|
+
|
|
582
|
+
export function statusDraftWithDerived(
|
|
583
|
+
draft: { kind: 'status'; state: SessionStreamState },
|
|
584
|
+
derived: DerivedStatusFields | undefined,
|
|
585
|
+
): StatusDraftWithDerived {
|
|
586
|
+
if (!derived) return draft
|
|
587
|
+
const state: SessionStreamState =
|
|
588
|
+
derived.agent_state === 'running' || derived.agent_state === 'waiting' ? 'working'
|
|
589
|
+
: derived.agent_state === 'ended' ? 'done'
|
|
590
|
+
: 'idle'
|
|
591
|
+
return {
|
|
592
|
+
kind: 'status',
|
|
593
|
+
state,
|
|
594
|
+
agent_state: derived.agent_state,
|
|
595
|
+
state_source: derived.state_source,
|
|
596
|
+
state_since: derived.state_since,
|
|
597
|
+
...(derived.waiting_kind ? { waiting_kind: derived.waiting_kind } : {}),
|
|
598
|
+
...(derived.waiting_detail !== undefined ? { waiting_detail: derived.waiting_detail } : {}),
|
|
599
|
+
...(derived.failure ? { failure: derived.failure } : {}),
|
|
600
|
+
...(state === 'idle' && derived.last_reply ? { last_reply: derived.last_reply } : {}),
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
460
604
|
/** Draft plus transport stamps, in the field order the contract shows. */
|
|
461
605
|
export function stampSessionEvent(draft: SessionStreamDraft, seq: number, at: number): SessionStreamEvent {
|
|
462
606
|
return { seq, at, ...draft }
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The Stop-driven drain kick (6.48.1).
|
|
2
|
+
//
|
|
3
|
+
// Before this, a queued follow-up waited for the 20 s sweep AND the holder's 30 s idle
|
|
4
|
+
// window: measured 35 s or more from the turn's end to delivery. The hooks now say the
|
|
5
|
+
// exact moment a turn ends (Stop), so the queue is drained THEN.
|
|
6
|
+
//
|
|
7
|
+
// ONE DRAIN IN FLIGHT, EXACTLY ONE FOLLOW-UP. The 20 s timer and the Stop kick share
|
|
8
|
+
// this guard, so a Stop landing during a sweep cannot start a second sweep that reads
|
|
9
|
+
// the same queue file and delivers the same sentence twice; it schedules one more pass
|
|
10
|
+
// after the current one, and a burst of Stops collapses into that one pass.
|
|
11
|
+
//
|
|
12
|
+
// A kick is spent only when a queue file exists for that session: a Stop on a session
|
|
13
|
+
// nobody queued for costs a directory listing and nothing else. The match is the full
|
|
14
|
+
// session id: a Claude queue file is always keyed by the full UUID, because the attach
|
|
15
|
+
// gate refuses anything else as `invalid_thread_id` before a turn can be queued, and
|
|
16
|
+
// every hook envelope carries the full id (QA, 2026-09-15: the 8-character branch this
|
|
17
|
+
// used to carry was unreachable).
|
|
18
|
+
//
|
|
19
|
+
// The sweep itself runs on the NEXT macrotask, never inside the caller: a kick arrives
|
|
20
|
+
// from the signal store inside the spool sweep, and the drain's synchronous head (a
|
|
21
|
+
// registry scan, a `ps` per owner, a tail read) belongs after that sweep and after the
|
|
22
|
+
// stream listeners for the same Stop have written their line.
|
|
23
|
+
|
|
24
|
+
export interface DrainKickDeps {
|
|
25
|
+
/** The sweep. Resolves the number delivered; rejects are swallowed here. */
|
|
26
|
+
drain: () => Promise<number>
|
|
27
|
+
/** Thread ids with a queue file right now, any provider. */
|
|
28
|
+
queuedThreadIds: () => string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DrainKick {
|
|
32
|
+
/** Run the sweep, or fold into the one in flight. Resolves when THIS request's pass has run. */
|
|
33
|
+
sweep(): Promise<void>
|
|
34
|
+
/** A session's turn ended: sweep (next macrotask) if a queue names it. Returns true when a sweep was requested. */
|
|
35
|
+
kick(sessionId: string): boolean
|
|
36
|
+
/**
|
|
37
|
+
* Kick once `ready()` answers true, polling every `everyMs` for up to `maxMs`; a kick
|
|
38
|
+
* at Stop time is too early when the engine closes the turn only after its Stop hooks
|
|
39
|
+
* (12-38 s on the release Mac), and nothing fires a hook when they return. Returns
|
|
40
|
+
* false at once when no queue names the session, so an unqueued Stop costs nothing.
|
|
41
|
+
*/
|
|
42
|
+
kickWhen(sessionId: string, ready: () => boolean, opts?: { everyMs?: number; maxMs?: number }): boolean
|
|
43
|
+
/** For health: passes run, kicks spent, kicks folded into a running pass, waits open. */
|
|
44
|
+
stats(): { passes: number; kicks: number; folded: number; inFlight: boolean; waiting: number }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const KICK_POLL_MS = 1_000
|
|
48
|
+
export const KICK_WAIT_MAX_MS = 60_000
|
|
49
|
+
|
|
50
|
+
/** The facts the kick plan reads off a session signal after one hook event. */
|
|
51
|
+
export interface KickSignalFacts {
|
|
52
|
+
turnOpen: boolean
|
|
53
|
+
subagentsOpen: number
|
|
54
|
+
ended: unknown
|
|
55
|
+
stopAt: number | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What one hook event asks of the kick: nothing, a kick now, or a kick once the registry says idle after `stopAt`. */
|
|
59
|
+
export type KickPlan = { kind: 'kick' } | { kind: 'kick_when_registry_idle'; stopAt: number } | null
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The composition root's rule, pure so it is pinned (QA N1, 2026-09-15):
|
|
63
|
+
*
|
|
64
|
+
* - a COS child's own SessionEnd frees the thread for the NEXT queued turn at once
|
|
65
|
+
* (without it a chained follow-up waited for the 20 s timer); nothing else a child
|
|
66
|
+
* does kicks, since its Stop is the turn this server is delivering;
|
|
67
|
+
* - the tab's Stop kicks once the registry flips idle after it (the Stop hooks have
|
|
68
|
+
* returned); a Stop with the turn still open, a sub-agent still open, or the session
|
|
69
|
+
* ended asks nothing;
|
|
70
|
+
* - a SubagentStop never kicks: a background sub-agent finishing after the Stop is
|
|
71
|
+
* answered by the model in a turn of its own, whose Stop kicks, and neither the hook
|
|
72
|
+
* short-circuit nor the B6 clause vouches for a session whose newest event is a
|
|
73
|
+
* SubagentStop, so a kick there could only fall through to the 30 s backstop.
|
|
74
|
+
*/
|
|
75
|
+
export function kickPlanFor(signal: KickSignalFacts, event: string, eventTs: number, child: boolean): KickPlan {
|
|
76
|
+
if (child) return event === 'SessionEnd' ? { kind: 'kick' } : null
|
|
77
|
+
if (event !== 'Stop') return null
|
|
78
|
+
if (signal.turnOpen || signal.subagentsOpen > 0 || signal.ended) return null
|
|
79
|
+
return { kind: 'kick_when_registry_idle', stopAt: typeof signal.stopAt === 'number' ? signal.stopAt : eventTs }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createDrainKick(deps: DrainKickDeps): DrainKick {
|
|
83
|
+
let inFlight: Promise<void> | null = null
|
|
84
|
+
let followUp: Promise<void> | null = null
|
|
85
|
+
const stats = { passes: 0, kicks: 0, folded: 0 }
|
|
86
|
+
|
|
87
|
+
const runOnce = async (): Promise<void> => {
|
|
88
|
+
stats.passes++
|
|
89
|
+
try { await deps.drain() } catch { /* the next pass retries; the sweep logs its own errors */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sweep = (): Promise<void> => {
|
|
93
|
+
if (!inFlight) {
|
|
94
|
+
inFlight = runOnce().finally(() => { inFlight = null })
|
|
95
|
+
return inFlight
|
|
96
|
+
}
|
|
97
|
+
// Exactly one follow-up pass, however many requests arrive while the first runs.
|
|
98
|
+
if (!followUp) {
|
|
99
|
+
stats.folded++
|
|
100
|
+
const current = inFlight
|
|
101
|
+
followUp = current.then(() => { followUp = null; return sweep() })
|
|
102
|
+
} else {
|
|
103
|
+
stats.folded++
|
|
104
|
+
}
|
|
105
|
+
return followUp
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const queueNames = (sessionId: string): boolean => {
|
|
109
|
+
const id = sessionId.toLowerCase()
|
|
110
|
+
return deps.queuedThreadIds().some(t => t.toLowerCase() === id)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const waits = new Map<string, ReturnType<typeof setInterval>>()
|
|
114
|
+
|
|
115
|
+
const kick = (sessionId: string): boolean => {
|
|
116
|
+
if (!queueNames(sessionId)) return false
|
|
117
|
+
stats.kicks++
|
|
118
|
+
setImmediate(() => { void sweep() })
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
sweep,
|
|
124
|
+
kick,
|
|
125
|
+
kickWhen(sessionId: string, ready: () => boolean, opts = {}): boolean {
|
|
126
|
+
if (!queueNames(sessionId)) return false
|
|
127
|
+
const key = sessionId.toLowerCase()
|
|
128
|
+
const existing = waits.get(key)
|
|
129
|
+
if (existing) { clearInterval(existing); waits.delete(key) }
|
|
130
|
+
let readyNow = false
|
|
131
|
+
try { readyNow = ready() } catch { readyNow = false }
|
|
132
|
+
if (readyNow) return kick(sessionId)
|
|
133
|
+
const everyMs = opts.everyMs ?? KICK_POLL_MS
|
|
134
|
+
const maxMs = opts.maxMs ?? KICK_WAIT_MAX_MS
|
|
135
|
+
const startedAt = Date.now()
|
|
136
|
+
const timer = setInterval(() => {
|
|
137
|
+
let ok = false
|
|
138
|
+
try { ok = ready() } catch { ok = false }
|
|
139
|
+
if (ok || Date.now() - startedAt >= maxMs) {
|
|
140
|
+
clearInterval(timer)
|
|
141
|
+
waits.delete(key)
|
|
142
|
+
// Past the wait the sweep still runs: the gate decides, and the 20 s timer would
|
|
143
|
+
// have anyway. Nothing here can deliver; only the gate can.
|
|
144
|
+
kick(sessionId)
|
|
145
|
+
}
|
|
146
|
+
}, everyMs)
|
|
147
|
+
timer.unref?.()
|
|
148
|
+
waits.set(key, timer)
|
|
149
|
+
return true
|
|
150
|
+
},
|
|
151
|
+
stats: () => ({ ...stats, inFlight: inFlight !== null, waiting: waits.size }),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -150,6 +150,23 @@ export interface OccupancyProbes {
|
|
|
150
150
|
* safety property here.
|
|
151
151
|
*/
|
|
152
152
|
transcriptMtimeMs?: (provider: OccupancyProvider, threadId: string) => number | null
|
|
153
|
+
/**
|
|
154
|
+
* THE B6 CLAUSE (6.48.1). Epoch ms of the holder's newest hook `Stop`, ONLY when the
|
|
155
|
+
* engine itself has closed the turn: that Stop is the session's newest hook event, no
|
|
156
|
+
* sub-agent is open, the session has not ended, AND the registry record says `idle`
|
|
157
|
+
* with `statusUpdatedAt` at or after the Stop (Claude flips it only when every Stop
|
|
158
|
+
* hook has returned, 12-38 s after the Stop on the release Mac, which is when a prompt
|
|
159
|
+
* queued at the desk would dequeue). Null otherwise, and always null for Codex.
|
|
160
|
+
* OPTIONAL, and its absence is the default: attached by `withHookTurnClock` at the
|
|
161
|
+
* composition root only when `COS_SESSION_HOOKS` is on, so the pure gate never reads
|
|
162
|
+
* the environment.
|
|
163
|
+
*
|
|
164
|
+
* What it buys: a foreign Desktop holder whose turn the engine has closed reads `idle`
|
|
165
|
+
* at once instead of after the 30 s transcript window. The transcript's mtime is not
|
|
166
|
+
* consulted: the bookkeeping rows Claude writes as the hooks finish are exactly what
|
|
167
|
+
* makes the file look busy at the moment it is safest to attach.
|
|
168
|
+
*/
|
|
169
|
+
holderTurnEndedAtMs?: (provider: OccupancyProvider, threadId: string) => number | null
|
|
153
170
|
/**
|
|
154
171
|
* Cursor Agent CLI session at `~/.cursor/chats/<hash>/<id>/`, or null when
|
|
155
172
|
* that id is not exactly one continuable chats dir. Occupancy MUST NOT import
|
|
@@ -237,6 +254,7 @@ export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: numb
|
|
|
237
254
|
*/
|
|
238
255
|
export type HolderActivity = 'working' | 'idle' | 'unknown'
|
|
239
256
|
|
|
257
|
+
|
|
240
258
|
export function holderActivity(mtimeMs: number | null | undefined, nowMs: number): HolderActivity {
|
|
241
259
|
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return 'unknown'
|
|
242
260
|
if (!Number.isFinite(nowMs)) return 'unknown'
|
|
@@ -261,11 +279,28 @@ function readHolderActivity(
|
|
|
261
279
|
nowMs: number,
|
|
262
280
|
): HolderActivity {
|
|
263
281
|
if (typeof probes.transcriptMtimeMs !== 'function') return 'unknown'
|
|
282
|
+
let mtime: number | null
|
|
264
283
|
try {
|
|
265
|
-
|
|
284
|
+
mtime = probes.transcriptMtimeMs(provider, threadId)
|
|
266
285
|
} catch {
|
|
267
286
|
return 'unknown'
|
|
268
287
|
}
|
|
288
|
+
const activity = holderActivity(mtime, nowMs)
|
|
289
|
+
if (activity !== 'working') return activity
|
|
290
|
+
// THE B6 CLAUSE. Only a `working` verdict is revisited, only for Claude, only with a
|
|
291
|
+
// real clock behind it, and only when the hook probe VOUCHES that the engine closed
|
|
292
|
+
// the turn (null is strict). `unknown` stays unknown: the clause narrows a refusal,
|
|
293
|
+
// never a doubt.
|
|
294
|
+
if (provider !== 'claude' || typeof probes.holderTurnEndedAtMs !== 'function' || typeof mtime !== 'number') return activity
|
|
295
|
+
let stopAt: number | null
|
|
296
|
+
try {
|
|
297
|
+
stopAt = probes.holderTurnEndedAtMs(provider, threadId)
|
|
298
|
+
} catch {
|
|
299
|
+
return activity
|
|
300
|
+
}
|
|
301
|
+
if (typeof stopAt !== 'number' || !Number.isFinite(stopAt)) return activity
|
|
302
|
+
if (stopAt > nowMs + ACTIVE_RECENTLY_WINDOW_MS) return activity
|
|
303
|
+
return 'idle'
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
/** A Claude registry filename is exactly `<pid>.json`. Not `*.json`. */
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { request } from 'node:http'
|
|
28
28
|
import type { QueuedThreadTurn } from './thread-turn-queue.js'
|
|
29
|
+
import { targetKey } from './agent-session-binding-store.js'
|
|
29
30
|
|
|
30
31
|
/** Per-request ceiling. Attach and turn both answer immediately; the turn route
|
|
31
32
|
* admits with 202 and does the long work in the background. */
|
|
@@ -97,16 +98,26 @@ export async function deliverQueuedTurnOverLoopback(
|
|
|
97
98
|
}
|
|
98
99
|
const bindingId = typeof attach.body.bindingId === 'string' ? attach.body.bindingId : ''
|
|
99
100
|
if (!bindingId) return { ok: false, reason: 'attach_no_binding' }
|
|
100
|
-
|
|
101
|
+
// 6.48.1: the turns route has required `epoch` and `targetKey` since the binding
|
|
102
|
+
// hardening, and refused this body as `invalid_request` (retryable, so the turn was
|
|
103
|
+
// refunded and held forever: every drained follow-up since then never landed; found by
|
|
104
|
+
// the 6.48.1 live proof, 2026-09-15). The epoch is the one the attach just minted and
|
|
105
|
+
// the target key is the server's own function of (provider, native id), exactly what
|
|
106
|
+
// the phone sends.
|
|
107
|
+
const epoch = typeof attach.body.epoch === 'number' && Number.isInteger(attach.body.epoch) && attach.body.epoch >= 1 ? attach.body.epoch : null
|
|
108
|
+
if (epoch === null) return { ok: false, reason: 'attach_no_epoch' }
|
|
109
|
+
const boundTo = typeof attach.body.boundTo === 'string' ? attach.body.boundTo : undefined
|
|
101
110
|
const sent = await post(
|
|
102
111
|
port, token,
|
|
103
112
|
`/api/agent-sessions/bindings/${encodeURIComponent(bindingId)}/turns`,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
{
|
|
114
|
+
clientTurnId: turn.clientTurnId,
|
|
115
|
+
prompt: turn.prompt,
|
|
116
|
+
epoch,
|
|
117
|
+
targetKey: targetKey(turn.provider, turn.threadId),
|
|
118
|
+
...(boundTo ? { boundTo } : {}),
|
|
119
|
+
},
|
|
108
120
|
)
|
|
109
|
-
// 202 is the success shape: admitted, delivered in the background, poll the ledger.
|
|
110
121
|
if (sent.status === 202 || sent.status === 200) return { ok: true }
|
|
111
122
|
// Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
|
|
112
123
|
// and no `error`. `retryable` is the server's OWN judgement about this refusal --
|