@raidiant/notifai 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +34 -0
- package/src/harnesses.ts +57 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
package/src/hooks.ts
CHANGED
|
@@ -11,8 +11,19 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import type {
|
|
15
|
-
|
|
14
|
+
import type {
|
|
15
|
+
LifecycleEndState,
|
|
16
|
+
ListRepliesResponse,
|
|
17
|
+
NotificationDraftT,
|
|
18
|
+
QuestionT,
|
|
19
|
+
ReplyView,
|
|
20
|
+
SubmissionReceipt,
|
|
21
|
+
} from '@raidiant/notifai-protocol'
|
|
22
|
+
import {
|
|
23
|
+
ApiCallError,
|
|
24
|
+
isRetryableReplyPollError,
|
|
25
|
+
type ApiClient,
|
|
26
|
+
} from './client.js'
|
|
16
27
|
import {
|
|
17
28
|
loadConfig,
|
|
18
29
|
projectSessionPointerPath,
|
|
@@ -23,20 +34,30 @@ import {
|
|
|
23
34
|
} from './config.js'
|
|
24
35
|
import { buildDraft } from './send.js'
|
|
25
36
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
37
|
+
import { withFileLock } from './file-lock.js'
|
|
38
|
+
import type { Logger } from './logging.js'
|
|
39
|
+
import {
|
|
40
|
+
HARNESS_CAPABILITIES,
|
|
41
|
+
HARNESSES,
|
|
42
|
+
type DeliveryRoute,
|
|
43
|
+
type Harness,
|
|
44
|
+
} from './harnesses.js'
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
47
|
* Harness hook handlers.
|
|
29
48
|
*
|
|
30
49
|
* The supported harnesses expose the same useful lifecycle joints: a turn-end
|
|
31
|
-
* event and an event that fires when the user submits a prompt. Claude Code
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* No question detection or context-window state is needed in either path.
|
|
50
|
+
* event and an event that fires when the user submits a prompt. Claude Code
|
|
51
|
+
* and Codex can continue directly from a turn-end answer. Harnesses without a
|
|
52
|
+
* proven exact-session continuation fail closed at question admission.
|
|
35
53
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
54
|
+
* Where the user is standing no longer decides anything here. It used to: the
|
|
55
|
+
* turn-end hook held the terminal for as long as it waited, so waiting was
|
|
56
|
+
* only defensible while the person was demonstrably not at the keyboard. On
|
|
57
|
+
* Claude Code the handler now returns instantly and the waiter runs out of
|
|
58
|
+
* band, and nothing is monopolised in the first place — so the question is
|
|
59
|
+
* simply whether the user wants to be reached, which they answer once in
|
|
60
|
+
* configuration rather than implicitly with every keystroke.
|
|
40
61
|
*/
|
|
41
62
|
|
|
42
63
|
/** Fields we read from harness hook JSON. Everything else is passed through. */
|
|
@@ -57,6 +78,8 @@ export interface HookEnvelope {
|
|
|
57
78
|
export interface SessionState {
|
|
58
79
|
/** Epoch ms of the user's last prompt in this session — our presence signal. */
|
|
59
80
|
last_prompt_at?: number
|
|
81
|
+
/** Epoch ms of the last observed Stop hook, distinct from prompt routing. */
|
|
82
|
+
last_stop_at?: number
|
|
60
83
|
/**
|
|
61
84
|
* Questions registered by `notifai ask`, in registration order, each
|
|
62
85
|
* awaiting escalation or its answer. A list, deliberately: registering a
|
|
@@ -79,11 +102,42 @@ export interface SessionState {
|
|
|
79
102
|
* costs nothing and a missed one costs a stale notification for ever.
|
|
80
103
|
*/
|
|
81
104
|
retiring?: RetiringQuestion[]
|
|
82
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Tracks bounded Stop continuations so a follow-up ask is delivered once.
|
|
107
|
+
* `count` is how many answer generations have run consecutively without the
|
|
108
|
+
* user taking a turn themselves; their next prompt starts it over.
|
|
109
|
+
*/
|
|
83
110
|
continuation?: {
|
|
84
111
|
answered_at: number
|
|
85
112
|
count: number
|
|
86
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* A phone answer durably captured but not yet acknowledged. It stays here
|
|
116
|
+
* until a delivery is acknowledged — by the route's own write, or by the
|
|
117
|
+
* successor Stop of a blocking continuation — so a crash before the answer
|
|
118
|
+
* reaches the harness replays it instead of erasing it.
|
|
119
|
+
*/
|
|
120
|
+
accepted?: AcceptedAnswerDelivery
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface AcceptedAnswerDelivery {
|
|
124
|
+
answers: AnsweredPending[]
|
|
125
|
+
remaining: number
|
|
126
|
+
recorded_at: number
|
|
127
|
+
/**
|
|
128
|
+
* Epoch ms when a route's own write handed this answer to the harness, with
|
|
129
|
+
* the route that performed it. Recorded so that "this answer was delivered"
|
|
130
|
+
* is a fact on the journal rather than something inferred from a harness flag
|
|
131
|
+
* that only one route ever sets.
|
|
132
|
+
*/
|
|
133
|
+
delivered_at?: number
|
|
134
|
+
delivered_route?: string
|
|
135
|
+
/**
|
|
136
|
+
* How many times this answer has been handed to any route. The route-agnostic
|
|
137
|
+
* loop backstop: bounded by `MAX_CONTINUATION_COUNT` in one place every route
|
|
138
|
+
* passes through.
|
|
139
|
+
*/
|
|
140
|
+
delivery_attempts?: number
|
|
87
141
|
}
|
|
88
142
|
|
|
89
143
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -127,6 +181,8 @@ const ORPHAN_TTL_MS = 24 * 3600 * 1000
|
|
|
127
181
|
const ORPHAN_QUEUE_CAP = 50
|
|
128
182
|
|
|
129
183
|
export interface PendingQuestion {
|
|
184
|
+
/** Stable local identity across racing state writers and submit recovery. */
|
|
185
|
+
question_id?: string
|
|
130
186
|
/** One-line summary: the single question's text, or the set's first. */
|
|
131
187
|
question: string
|
|
132
188
|
/**
|
|
@@ -147,69 +203,75 @@ export interface PendingQuestion {
|
|
|
147
203
|
collapse_key?: string
|
|
148
204
|
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
149
205
|
device_ids?: string[]
|
|
206
|
+
/** Absolute end of the server reply window and its local continuation owner. */
|
|
207
|
+
reply_deadline_at?: number
|
|
208
|
+
/** Frozen before the first network byte, so an ambiguous submit is replayable. */
|
|
209
|
+
submission?: PendingSubmissionIntent
|
|
150
210
|
}
|
|
151
211
|
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
interface PendingSubmissionIntent {
|
|
213
|
+
request_id: string
|
|
214
|
+
idempotency_key: string
|
|
215
|
+
collapse_key: string
|
|
216
|
+
device_ids: string[]
|
|
217
|
+
draft: NotificationDraftT
|
|
218
|
+
owner_deadline_at: number
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
222
|
+
ids: string[]
|
|
223
|
+
display: string
|
|
224
|
+
} {
|
|
225
|
+
const ids = entries.flatMap((entry) =>
|
|
226
|
+
entry.request_id === undefined ? [] : [entry.request_id],
|
|
227
|
+
)
|
|
228
|
+
return { ids, display: ids.join(', ') }
|
|
229
|
+
}
|
|
154
230
|
|
|
155
231
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
232
|
+
* The waiter's whole wall clock: the terminal-first timer, submission, and the
|
|
233
|
+
* wait for an answer, from the moment the turn-end hook starts.
|
|
234
|
+
*
|
|
235
|
+
* One plain ceiling, deliberately not a budget carved into reserves. The
|
|
236
|
+
* reserves existed to fit every phase inside a *held* turn; on Claude Code the
|
|
237
|
+
* turn is not held any more, and on a host that still holds one the useful
|
|
238
|
+
* question is only how long that host is willing to wait in total.
|
|
159
239
|
*/
|
|
160
|
-
const
|
|
240
|
+
export const WAITER_CEILING_SECONDS = 480
|
|
161
241
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
| 'no-signal'
|
|
242
|
+
/**
|
|
243
|
+
* The wire contract's shortest reply window. A question submitted with less
|
|
244
|
+
* than this is rejected outright, and accepting one would in any case let the
|
|
245
|
+
* server go on taking an answer after the waiter that owns it has returned.
|
|
246
|
+
*/
|
|
247
|
+
const MIN_REPLY_WINDOW_SECONDS = 60
|
|
169
248
|
|
|
170
249
|
/**
|
|
171
250
|
* The terminal-first wait: the question sits in the terminal for
|
|
172
251
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
173
252
|
* devices.
|
|
174
253
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
254
|
+
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
255
|
+
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
256
|
+
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
257
|
+
* otherwise locked out of their own prompt. Nothing is monopolised now, so
|
|
258
|
+
* there is nothing to watch for and no machine this cannot run on.
|
|
180
259
|
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* want the question to reach them whether or not they are at the keyboard, so
|
|
186
|
-
* there is nothing to watch for and this becomes what its name always claimed —
|
|
187
|
-
* a plain timer. It then works on machines with no idle source too.
|
|
260
|
+
* The window is measured from registration, not from the turn's end: a
|
|
261
|
+
* question the agent asked five minutes ago while it kept working has already
|
|
262
|
+
* served its wait. A stamp from the future would otherwise consume the whole
|
|
263
|
+
* ceiling and leave no room to answer, so it starts no later than now.
|
|
188
264
|
*/
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// outside a plausible range restarts the window from now.
|
|
200
|
-
const elapsed = ctx.now() - askedAt
|
|
201
|
-
const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now()
|
|
202
|
-
const deadline = start + graceSeconds * 1000
|
|
203
|
-
|
|
204
|
-
for (;;) {
|
|
205
|
-
if (ctx.config.require_idle.value) {
|
|
206
|
-
const idle = ctx.idleSeconds()
|
|
207
|
-
if (idle === null) return 'no-signal'
|
|
208
|
-
if (idle < threshold) return 'user-returned'
|
|
209
|
-
}
|
|
210
|
-
if (ctx.now() >= deadline) return 'absent'
|
|
211
|
-
await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())))
|
|
212
|
-
}
|
|
265
|
+
async function awaitTerminalFirstWindow(
|
|
266
|
+
ctx: HookContext,
|
|
267
|
+
askedAt: number,
|
|
268
|
+
ceilingAt: number,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const graceSeconds = ctx.config.ask_grace_seconds.value
|
|
271
|
+
if (graceSeconds <= 0) return
|
|
272
|
+
const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt)
|
|
273
|
+
const remaining = until - ctx.now()
|
|
274
|
+
if (remaining > 0) await ctx.sleep(remaining)
|
|
213
275
|
}
|
|
214
276
|
|
|
215
277
|
export interface HookOutcome {
|
|
@@ -217,6 +279,61 @@ export interface HookOutcome {
|
|
|
217
279
|
stdout?: string
|
|
218
280
|
/** Diagnostics; harnesses surface hook stderr in the transcript. */
|
|
219
281
|
notes: string[]
|
|
282
|
+
/** Structured lifecycle detail that belongs on hook.end without user text. */
|
|
283
|
+
log?: Record<string, unknown>
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** An accepted continuation ready for whichever host owns the last meter. */
|
|
287
|
+
export interface ContinuationEvent {
|
|
288
|
+
context: string
|
|
289
|
+
answers: number
|
|
290
|
+
remaining: number
|
|
291
|
+
request_ids: string[]
|
|
292
|
+
journal_recorded_at: number
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* How much a route's own return proves about where the answer ended up.
|
|
297
|
+
*
|
|
298
|
+
* Acknowledgement belongs to the delivery, not to the harness envelope: routes
|
|
299
|
+
* end in different places, so no single field on a hook payload can speak for
|
|
300
|
+
* all of them. `stop_hook_active` is the harness confirming that a *blocking
|
|
301
|
+
* continuation* was admitted; it stays false for ever on a route that starts a
|
|
302
|
+
* brand-new turn instead of continuing this one, so a journal keyed to it alone
|
|
303
|
+
* never settles and every later turn-end redelivers the same answer.
|
|
304
|
+
*
|
|
305
|
+
* - `delivered` — the route completed a write to the harness itself (an inbox
|
|
306
|
+
* socket, a cold resume). Delivery is not consumption: the write proves the
|
|
307
|
+
* harness accepted the message, never that the model acted on it. But nothing
|
|
308
|
+
* later will ever prove more, and redelivering an answer without end is
|
|
309
|
+
* strictly worse than settling on the write, so the journal settles here.
|
|
310
|
+
* - `stdout` — the answer is this process's stdout, which the harness reads only
|
|
311
|
+
* after this process exits. Nothing this process can write is proof, so the
|
|
312
|
+
* journal waits for the successor Stop's `stop_hook_active`, and a crash
|
|
313
|
+
* before stdout replays the answer instead of losing it.
|
|
314
|
+
* - `held` — the route handed nothing over; the journal replays the answer.
|
|
315
|
+
*/
|
|
316
|
+
export type DeliveryAcknowledgement = 'delivered' | 'stdout' | 'held'
|
|
317
|
+
|
|
318
|
+
/** What a route returns: the hook's outcome plus what the attempt proved. */
|
|
319
|
+
export interface DeliveryOutcome {
|
|
320
|
+
stdout?: string
|
|
321
|
+
notes?: string[]
|
|
322
|
+
log?: Record<string, unknown>
|
|
323
|
+
acknowledgement: DeliveryAcknowledgement
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Host adapter injected into the waiter; no route is implemented by the waiter. */
|
|
327
|
+
export interface EscalationDeliveryRoute {
|
|
328
|
+
kind: Exclude<DeliveryRoute, 'unsupported'>
|
|
329
|
+
deliver(event: ContinuationEvent): Promise<DeliveryOutcome>
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface EscalationWaiterOptions {
|
|
333
|
+
sessionId: string
|
|
334
|
+
envelope: HookEnvelope
|
|
335
|
+
route: EscalationDeliveryRoute
|
|
336
|
+
processDeadlineAt?: number
|
|
220
337
|
}
|
|
221
338
|
|
|
222
339
|
function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
@@ -272,55 +389,6 @@ function updateSessionState(
|
|
|
272
389
|
})
|
|
273
390
|
}
|
|
274
391
|
|
|
275
|
-
/**
|
|
276
|
-
* Serialize a short read-modify-write without ever exposing partial JSON.
|
|
277
|
-
* A crashed holder is recoverable after 30 seconds; releases compare their
|
|
278
|
-
* random token under the same cooperating lock discipline before unlinking.
|
|
279
|
-
*/
|
|
280
|
-
const FILE_LOCK_STALE_MS = 30_000
|
|
281
|
-
const FILE_LOCK_WAIT_MS = 1_000
|
|
282
|
-
const FILE_LOCK_POLL_MS = 5
|
|
283
|
-
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
284
|
-
|
|
285
|
-
function withFileLock<T>(file: string, action: () => T): T {
|
|
286
|
-
mkdirSync(path.dirname(file), { recursive: true })
|
|
287
|
-
const token = randomBytes(12).toString('base64url')
|
|
288
|
-
const deadline = Date.now() + FILE_LOCK_WAIT_MS
|
|
289
|
-
for (;;) {
|
|
290
|
-
try {
|
|
291
|
-
const handle = openSync(file, 'wx', 0o600)
|
|
292
|
-
try {
|
|
293
|
-
writeFileSync(handle, `${JSON.stringify({ token, at: Date.now() })}\n`)
|
|
294
|
-
} finally {
|
|
295
|
-
closeSync(handle)
|
|
296
|
-
}
|
|
297
|
-
break
|
|
298
|
-
} catch (err) {
|
|
299
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
300
|
-
let stale = false
|
|
301
|
-
try {
|
|
302
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
303
|
-
stale = typeof held.at !== 'number' || Date.now() - held.at >= FILE_LOCK_STALE_MS
|
|
304
|
-
} catch {
|
|
305
|
-
stale = true
|
|
306
|
-
}
|
|
307
|
-
if (stale) rmSync(file, { force: true })
|
|
308
|
-
else if (Date.now() >= deadline) throw new Error(`timed out waiting for state lock ${file}`)
|
|
309
|
-
else Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
try {
|
|
313
|
-
return action()
|
|
314
|
-
} finally {
|
|
315
|
-
try {
|
|
316
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { token?: unknown }
|
|
317
|
-
if (held.token === token) rmSync(file, { force: true })
|
|
318
|
-
} catch {
|
|
319
|
-
// A replaced or externally removed lock is no longer ours to release.
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
392
|
/**
|
|
325
393
|
* Session state a crashed harness left behind.
|
|
326
394
|
*
|
|
@@ -384,10 +452,11 @@ export function pruneAbandonedSessions(
|
|
|
384
452
|
* Exclusive create is atomic on POSIX, so exactly one process gets the claim
|
|
385
453
|
* and the other steps aside. A short guard serializes stale replacement with
|
|
386
454
|
* contenders and releases; random ownership tokens keep an old holder from
|
|
387
|
-
* unlinking its replacement. A
|
|
388
|
-
*
|
|
455
|
+
* unlinking its replacement. A live PID owns the claim regardless of age; a
|
|
456
|
+
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
457
|
+
* no trustworthy PID fall back to an age limit.
|
|
389
458
|
*/
|
|
390
|
-
const CLAIM_TTL_MS =
|
|
459
|
+
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
|
|
391
460
|
const heldClaims = new Map<string, string>()
|
|
392
461
|
|
|
393
462
|
export function claimQuestionPush(
|
|
@@ -412,9 +481,13 @@ export function claimQuestionPush(
|
|
|
412
481
|
} catch {
|
|
413
482
|
// Held. Break it only if whoever holds it cannot still be running.
|
|
414
483
|
try {
|
|
415
|
-
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown }
|
|
484
|
+
const held = JSON.parse(readFileSync(file, 'utf8')) as { at?: unknown; pid?: unknown }
|
|
416
485
|
const age = typeof held.at === 'number' ? now - held.at : Number.POSITIVE_INFINITY
|
|
417
|
-
if (
|
|
486
|
+
if (typeof held.pid === 'number') {
|
|
487
|
+
if (processIsAlive(held.pid)) return false
|
|
488
|
+
} else if (age >= 0 && age < CLAIM_TTL_MS) {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
418
491
|
} catch {
|
|
419
492
|
// Unreadable or corrupt: treat as abandoned.
|
|
420
493
|
}
|
|
@@ -436,6 +509,15 @@ export function claimQuestionPush(
|
|
|
436
509
|
}
|
|
437
510
|
}
|
|
438
511
|
|
|
512
|
+
function processIsAlive(pid: number): boolean {
|
|
513
|
+
try {
|
|
514
|
+
process.kill(pid, 0)
|
|
515
|
+
return true
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
439
521
|
export function releaseQuestionPush(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
440
522
|
const file = claimPath(sessionId, env)
|
|
441
523
|
const token = heldClaims.get(file)
|
|
@@ -490,7 +572,11 @@ function claimPath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
|
490
572
|
*/
|
|
491
573
|
const MAX_STORED_QUESTION_CHARS = 2000
|
|
492
574
|
|
|
493
|
-
|
|
575
|
+
interface StoredProjectSessionPointer extends ProjectSessionPointer {
|
|
576
|
+
updatedAt: number
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Records every live session working in a directory, for `notifai ask`. */
|
|
494
580
|
export function writeProjectSession(
|
|
495
581
|
cwd: string,
|
|
496
582
|
env: NodeJS.ProcessEnv,
|
|
@@ -500,9 +586,21 @@ export function writeProjectSession(
|
|
|
500
586
|
): void {
|
|
501
587
|
const file = projectSessionPointerPath(cwd, env)
|
|
502
588
|
withFileLock(`${file}.lock`, () => {
|
|
589
|
+
const existing = readStoredProjectSessionPointers(file).filter(
|
|
590
|
+
(entry) => entry.sessionId !== sessionId && now - entry.updatedAt <= 24 * 3600 * 1000,
|
|
591
|
+
)
|
|
503
592
|
atomicWriteFileSync(
|
|
504
593
|
file,
|
|
505
|
-
`${JSON.stringify({
|
|
594
|
+
`${JSON.stringify({
|
|
595
|
+
sessions: [
|
|
596
|
+
...existing.map((entry) => ({
|
|
597
|
+
session_id: entry.sessionId,
|
|
598
|
+
updated_at: entry.updatedAt,
|
|
599
|
+
harness: entry.harness,
|
|
600
|
+
})),
|
|
601
|
+
{ session_id: sessionId, updated_at: now, harness },
|
|
602
|
+
],
|
|
603
|
+
})}\n`,
|
|
506
604
|
)
|
|
507
605
|
})
|
|
508
606
|
}
|
|
@@ -512,6 +610,34 @@ export interface ProjectSessionPointer {
|
|
|
512
610
|
harness: HookHarness
|
|
513
611
|
}
|
|
514
612
|
|
|
613
|
+
function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
|
|
614
|
+
if (!existsSync(file)) return []
|
|
615
|
+
try {
|
|
616
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { sessions?: unknown }
|
|
617
|
+
if (!Array.isArray(parsed.sessions)) return []
|
|
618
|
+
return parsed.sessions.flatMap((candidate): StoredProjectSessionPointer[] => {
|
|
619
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return []
|
|
620
|
+
const entry = candidate as {
|
|
621
|
+
session_id?: unknown
|
|
622
|
+
updated_at?: unknown
|
|
623
|
+
harness?: unknown
|
|
624
|
+
}
|
|
625
|
+
if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
|
|
626
|
+
if (typeof entry.updated_at !== 'number') return []
|
|
627
|
+
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
|
|
628
|
+
return [
|
|
629
|
+
{
|
|
630
|
+
sessionId: entry.session_id,
|
|
631
|
+
updatedAt: entry.updated_at,
|
|
632
|
+
harness: entry.harness as HookHarness,
|
|
633
|
+
},
|
|
634
|
+
]
|
|
635
|
+
})
|
|
636
|
+
} catch {
|
|
637
|
+
return []
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
515
641
|
/**
|
|
516
642
|
* Resolves the session working in `cwd`. Stale pointers are ignored rather than
|
|
517
643
|
* trusted: an id left by a session that ended days ago would send the question
|
|
@@ -526,37 +652,71 @@ export function readProjectSession(
|
|
|
526
652
|
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
|
|
527
653
|
}
|
|
528
654
|
|
|
655
|
+
/** A live pointer keeps when it was last written, so callers can rank by it. */
|
|
656
|
+
export interface LiveProjectSessionPointer extends ProjectSessionPointer {
|
|
657
|
+
updatedAt: number
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Every session that has fired a hook in this directory recently, newest first.
|
|
662
|
+
*
|
|
663
|
+
* This is the only local record of which harness sessions are actually alive
|
|
664
|
+
* here, which makes it the tiebreaker when the environment alone cannot say
|
|
665
|
+
* which harness owns the current shell.
|
|
666
|
+
*/
|
|
667
|
+
export function readLiveProjectSessionPointers(
|
|
668
|
+
cwd: string,
|
|
669
|
+
env: NodeJS.ProcessEnv,
|
|
670
|
+
now: number,
|
|
671
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
672
|
+
): LiveProjectSessionPointer[] {
|
|
673
|
+
const file = projectSessionPointerPath(cwd, env)
|
|
674
|
+
return readStoredProjectSessionPointers(file)
|
|
675
|
+
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
676
|
+
.filter((entry) => {
|
|
677
|
+
try {
|
|
678
|
+
// A pointer is routing evidence only while its session state still
|
|
679
|
+
// exists and parses. SessionEnd and explicit cleanup therefore
|
|
680
|
+
// invalidate it even when a crash prevented removal from this index.
|
|
681
|
+
const sessionFile = sessionStatePath(entry.sessionId, env)
|
|
682
|
+
if (!existsSync(sessionFile)) return false
|
|
683
|
+
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
684
|
+
return typeof state === 'object' && state !== null && !Array.isArray(state)
|
|
685
|
+
} catch {
|
|
686
|
+
return false
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
690
|
+
.map((entry) => ({
|
|
691
|
+
sessionId: entry.sessionId,
|
|
692
|
+
harness: entry.harness,
|
|
693
|
+
updatedAt: entry.updatedAt,
|
|
694
|
+
}))
|
|
695
|
+
}
|
|
696
|
+
|
|
529
697
|
export function readProjectSessionPointer(
|
|
530
698
|
cwd: string,
|
|
531
699
|
env: NodeJS.ProcessEnv,
|
|
532
700
|
now: number,
|
|
533
701
|
maxAgeMs = 24 * 3600 * 1000,
|
|
534
702
|
): ProjectSessionPointer | null {
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
try {
|
|
538
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
539
|
-
session_id?: unknown
|
|
540
|
-
updated_at?: unknown
|
|
541
|
-
harness?: unknown
|
|
542
|
-
}
|
|
543
|
-
if (typeof parsed.session_id !== 'string' || parsed.session_id === '') return null
|
|
544
|
-
if (typeof parsed.updated_at !== 'number' || now - parsed.updated_at > maxAgeMs) return null
|
|
545
|
-
if (!(HOOK_HARNESSES as readonly unknown[]).includes(parsed.harness)) return null
|
|
546
|
-
// A pointer is routing evidence only while its session state still exists
|
|
547
|
-
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
548
|
-
// when a crash prevented the pointer file itself from being removed.
|
|
549
|
-
const sessionFile = sessionStatePath(parsed.session_id, env)
|
|
550
|
-
if (!existsSync(sessionFile)) return null
|
|
551
|
-
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
552
|
-
if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
|
|
553
|
-
return { sessionId: parsed.session_id, harness: parsed.harness as HookHarness }
|
|
554
|
-
} catch {
|
|
555
|
-
return null
|
|
556
|
-
}
|
|
703
|
+
const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
|
|
704
|
+
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
|
|
557
705
|
}
|
|
558
706
|
|
|
559
|
-
|
|
707
|
+
export function readMatchingProjectSessionPointer(
|
|
708
|
+
cwd: string,
|
|
709
|
+
env: NodeJS.ProcessEnv,
|
|
710
|
+
now: number,
|
|
711
|
+
sessionId: string,
|
|
712
|
+
harness: HookHarness,
|
|
713
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
714
|
+
): ProjectSessionPointer | null {
|
|
715
|
+
const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
|
|
716
|
+
(entry) => entry.sessionId === sessionId && entry.harness === harness,
|
|
717
|
+
)
|
|
718
|
+
return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
|
|
719
|
+
}
|
|
560
720
|
|
|
561
721
|
function clearMatchingProjectSession(
|
|
562
722
|
cwd: string,
|
|
@@ -565,75 +725,31 @@ function clearMatchingProjectSession(
|
|
|
565
725
|
): void {
|
|
566
726
|
const file = projectSessionPointerPath(cwd, env)
|
|
567
727
|
withFileLock(`${file}.lock`, () => {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
728
|
+
const remaining = readStoredProjectSessionPointers(file).filter(
|
|
729
|
+
(entry) => entry.sessionId !== sessionId,
|
|
730
|
+
)
|
|
731
|
+
if (remaining.length === 0) {
|
|
732
|
+
rmSync(file, { force: true })
|
|
733
|
+
return
|
|
573
734
|
}
|
|
735
|
+
atomicWriteFileSync(
|
|
736
|
+
file,
|
|
737
|
+
`${JSON.stringify({
|
|
738
|
+
sessions: remaining.map((entry) => ({
|
|
739
|
+
session_id: entry.sessionId,
|
|
740
|
+
updated_at: entry.updatedAt,
|
|
741
|
+
harness: entry.harness,
|
|
742
|
+
})),
|
|
743
|
+
})}\n`,
|
|
744
|
+
)
|
|
574
745
|
})
|
|
575
746
|
}
|
|
576
747
|
|
|
577
|
-
/**
|
|
578
|
-
* Absent means the user is not at this machine's keyboard.
|
|
579
|
-
*
|
|
580
|
-
* OS idle time answers that directly, so where it exists it decides alone.
|
|
581
|
-
* Silence since the user's last prompt is only ever a proxy for it, and a poor
|
|
582
|
-
* one — it is wrong in both directions:
|
|
583
|
-
*
|
|
584
|
-
* - Too long: it counts the agent's own turn, so a user watching a build was
|
|
585
|
-
* read as absent and had the question pushed at them.
|
|
586
|
-
* - Too short: a session that has just been spawned always has a fresh
|
|
587
|
-
* prompt, so its FIRST question could never escalate however long its
|
|
588
|
-
* owner had been gone. That is the "kick off some agents and
|
|
589
|
-
* walk away" case this feature mainly exists for, and requiring both
|
|
590
|
-
* signals to agree is what broke it.
|
|
591
|
-
*
|
|
592
|
-
* The proxy therefore survives only as the fallback for machines with no idle
|
|
593
|
-
* source. There, never having seen a prompt is not evidence of absence — it is
|
|
594
|
-
* a missing `UserPromptSubmit` hook — so it resolves to "present".
|
|
595
|
-
*
|
|
596
|
-
* Answering from a companion device does not make the user present; only touching this
|
|
597
|
-
* machine does. Answering on a device is evidence of being away from it.
|
|
598
|
-
*
|
|
599
|
-
* All of which only matters if presence is being consulted at all. With
|
|
600
|
-
* `require_idle` off the user has said their whereabouts are not a
|
|
601
|
-
* precondition, so this stops guessing and answers yes.
|
|
602
|
-
*/
|
|
603
|
-
export function isUserAway(
|
|
604
|
-
state: SessionState,
|
|
605
|
-
config: CliConfig,
|
|
606
|
-
now: number,
|
|
607
|
-
idleSeconds: number | null,
|
|
608
|
-
): boolean {
|
|
609
|
-
if (!config.require_idle.value) return true
|
|
610
|
-
const threshold = config.away_after_seconds.value
|
|
611
|
-
if (idleSeconds !== null) return idleSeconds >= threshold
|
|
612
|
-
if (state.last_prompt_at === undefined) return false
|
|
613
|
-
const silence = now - state.last_prompt_at
|
|
614
|
-
// Both signs of a clock jump produce a wrong answer here, and the fallback
|
|
615
|
-
// path has no monotonic reference to check against: a forward jump (NTP
|
|
616
|
-
// correction, VM resume) hijacks a terminal whose user is sitting right
|
|
617
|
-
// there, and a backward one suppresses escalation for someone genuinely
|
|
618
|
-
// gone. Neither delta is evidence of anything, so it resolves the way every
|
|
619
|
-
// other absence of evidence does — present.
|
|
620
|
-
if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS) return false
|
|
621
|
-
return silence >= threshold * 1000
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Beyond this, silence says more about the clock or an abandoned session file
|
|
626
|
-
* than about the user. Matches the project pointer's staleness horizon.
|
|
627
|
-
*/
|
|
628
|
-
const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000
|
|
629
|
-
|
|
630
748
|
export interface HookContext {
|
|
631
749
|
client: ApiClient
|
|
632
750
|
config: CliConfig
|
|
633
751
|
env: NodeJS.ProcessEnv
|
|
634
752
|
now: () => number
|
|
635
|
-
/** Seconds since the last keyboard/mouse event, or null if unknowable. */
|
|
636
|
-
idleSeconds: () => number | null
|
|
637
753
|
/** Injected so tests advance a virtual clock instead of sleeping. */
|
|
638
754
|
sleep: (milliseconds: number) => Promise<void>
|
|
639
755
|
/** Bounded wait for the first reply; injected so tests do not sleep. */
|
|
@@ -641,21 +757,54 @@ export interface HookContext {
|
|
|
641
757
|
requestId: string,
|
|
642
758
|
timeoutSeconds: number,
|
|
643
759
|
) => Promise<{ replies: ReplyView[]; timedOut: boolean; degraded?: boolean }>
|
|
644
|
-
/**
|
|
760
|
+
/** The active harness selects the native continuation output adapter. */
|
|
645
761
|
harness?: HookHarness
|
|
762
|
+
/**
|
|
763
|
+
* The local record. A hook's decisions are invisible from everywhere else —
|
|
764
|
+
* its stderr belongs to the harness and its usual outcome is to do nothing —
|
|
765
|
+
* so this is the only account of why a question did or did not travel.
|
|
766
|
+
*/
|
|
767
|
+
log?: Logger
|
|
646
768
|
}
|
|
647
769
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
770
|
+
/**
|
|
771
|
+
* Record one gate decision.
|
|
772
|
+
*
|
|
773
|
+
* `verdict` is what happened to the question and `reason` is the closed-set
|
|
774
|
+
* name of the rule that decided it, so an agent can ask "why was it held" with
|
|
775
|
+
* a filter rather than by matching on prose. The prose still exists — it is what
|
|
776
|
+
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
777
|
+
* filter cannot rely on is a log nobody filters.
|
|
778
|
+
*/
|
|
779
|
+
export type GateReason =
|
|
780
|
+
| 'no-session'
|
|
781
|
+
| 'no-question'
|
|
782
|
+
| 'answered'
|
|
783
|
+
| 'already-asked'
|
|
784
|
+
| 'continuation-repeat'
|
|
785
|
+
| 'continuation-limit'
|
|
786
|
+
| 'delivery-limit'
|
|
787
|
+
| 'notifications-off'
|
|
788
|
+
| 'claimed-elsewhere'
|
|
789
|
+
| 'no-devices'
|
|
790
|
+
| 'proceeding'
|
|
791
|
+
|
|
792
|
+
function gate(
|
|
793
|
+
ctx: HookContext,
|
|
794
|
+
verdict: 'held' | 'proceeding',
|
|
795
|
+
reason: GateReason,
|
|
796
|
+
data: Record<string, unknown> = {},
|
|
797
|
+
): void {
|
|
798
|
+
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
|
|
655
799
|
}
|
|
656
800
|
|
|
657
|
-
|
|
658
|
-
|
|
801
|
+
export type HookHarness = Harness
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* One round of the waiter's reply poll. Short enough that several questions
|
|
805
|
+
* still share one wall clock, long enough not to hammer the server.
|
|
806
|
+
*/
|
|
807
|
+
const REPLY_POLL_SECONDS = 5
|
|
659
808
|
|
|
660
809
|
/**
|
|
661
810
|
* Push a question and block for the answer.
|
|
@@ -672,7 +821,7 @@ const REPLY_PRESENCE_POLL_SECONDS = 5
|
|
|
672
821
|
* Put one registered question on the user's devices. Submission only — the
|
|
673
822
|
* wait happens once, across every live question, in the caller.
|
|
674
823
|
*/
|
|
675
|
-
async function
|
|
824
|
+
async function prepareQuestionSubmission(
|
|
676
825
|
ctx: HookContext,
|
|
677
826
|
options: {
|
|
678
827
|
title: string
|
|
@@ -684,8 +833,10 @@ async function submitQuestion(
|
|
|
684
833
|
session?: string | undefined
|
|
685
834
|
/** How long the server keeps accepting an answer. */
|
|
686
835
|
windowSeconds: number
|
|
836
|
+
/** Absolute owner deadline persisted before submit. */
|
|
837
|
+
ownerDeadlineAt: number
|
|
687
838
|
},
|
|
688
|
-
): Promise<
|
|
839
|
+
): Promise<PendingSubmissionIntent | { error: string }> {
|
|
689
840
|
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`
|
|
690
841
|
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
691
842
|
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
@@ -708,63 +859,112 @@ async function submitQuestion(
|
|
|
708
859
|
})
|
|
709
860
|
if (!build.ok) return { error: build.error }
|
|
710
861
|
|
|
862
|
+
return {
|
|
863
|
+
request_id: `req_${randomBytes(18).toString('base64url')}`,
|
|
864
|
+
idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
|
|
865
|
+
collapse_key: collapseKey,
|
|
866
|
+
device_ids: answerable,
|
|
867
|
+
draft: build.draft,
|
|
868
|
+
owner_deadline_at: options.ownerDeadlineAt,
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function submitQuestion(
|
|
873
|
+
ctx: HookContext,
|
|
874
|
+
intent: PendingSubmissionIntent,
|
|
875
|
+
): Promise<SubmissionReceipt> {
|
|
711
876
|
const receipt = await ctx.client.submit(
|
|
712
|
-
{
|
|
877
|
+
{
|
|
878
|
+
request_id: intent.request_id,
|
|
879
|
+
idempotency_key: intent.idempotency_key,
|
|
880
|
+
draft: intent.draft,
|
|
881
|
+
},
|
|
713
882
|
0,
|
|
714
883
|
)
|
|
715
|
-
|
|
884
|
+
if (receipt.request_id !== intent.request_id) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`,
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
return receipt
|
|
716
890
|
}
|
|
717
891
|
|
|
718
892
|
/**
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
893
|
+
* The waiter's wait: one bounded poll across every live question at once.
|
|
894
|
+
*
|
|
895
|
+
* Each round polls all of them concurrently, so several questions cost the
|
|
896
|
+
* same wall clock as one; the first round that finds any reply returns
|
|
897
|
+
* everything found in that round, and whatever was not answered stays
|
|
898
|
+
* registered. A question whose polling is permanently rejected drops out of
|
|
899
|
+
* the round rather than failing the whole wait.
|
|
723
900
|
*/
|
|
724
|
-
async function
|
|
901
|
+
async function waitForAnyReply(
|
|
725
902
|
ctx: HookContext,
|
|
726
903
|
requestIds: string[],
|
|
727
904
|
timeoutSeconds: number,
|
|
728
|
-
): Promise<{
|
|
905
|
+
): Promise<{
|
|
906
|
+
byRequest: Map<string, ReplyView[]>
|
|
907
|
+
degraded: boolean
|
|
908
|
+
permanentFailures: Map<string, string>
|
|
909
|
+
}> {
|
|
729
910
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
730
911
|
let degraded = false
|
|
731
912
|
let firstPoll = true
|
|
913
|
+
const permanentFailures = new Map<string, string>()
|
|
732
914
|
|
|
733
915
|
for (;;) {
|
|
734
|
-
if (ctx.config.require_idle.value) {
|
|
735
|
-
const idle = ctx.idleSeconds()
|
|
736
|
-
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
737
|
-
return { byRequest: new Map(), degraded, userReturned: true }
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
916
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
742
917
|
if (!firstPoll && remainingMs === 0) {
|
|
743
|
-
return { byRequest: new Map(), degraded,
|
|
918
|
+
return { byRequest: new Map(), degraded, permanentFailures }
|
|
744
919
|
}
|
|
745
920
|
firstPoll = false
|
|
746
|
-
const pollSeconds = Math.min(
|
|
747
|
-
REPLY_PRESENCE_POLL_SECONDS,
|
|
748
|
-
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
749
|
-
)
|
|
921
|
+
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
|
|
750
922
|
const results = await Promise.all(
|
|
751
|
-
requestIds
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
923
|
+
requestIds
|
|
924
|
+
.filter((requestId) => !permanentFailures.has(requestId))
|
|
925
|
+
.map(async (requestId) => {
|
|
926
|
+
try {
|
|
927
|
+
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) }
|
|
928
|
+
} catch (err) {
|
|
929
|
+
if (isRetryableReplyPollError(err)) {
|
|
930
|
+
return { requestId, replies: [] as ReplyView[], degraded: true }
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
requestId,
|
|
934
|
+
replies: [] as ReplyView[],
|
|
935
|
+
degraded: false,
|
|
936
|
+
permanentFailure: replyPollFailure(err),
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}),
|
|
758
940
|
)
|
|
759
941
|
const byRequest = new Map<string, ReplyView[]>()
|
|
760
942
|
for (const result of results) {
|
|
761
943
|
degraded ||= result.degraded === true
|
|
944
|
+
if (result.permanentFailure !== undefined) {
|
|
945
|
+
permanentFailures.set(result.requestId, result.permanentFailure)
|
|
946
|
+
}
|
|
762
947
|
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
763
948
|
}
|
|
764
|
-
if (byRequest.size > 0
|
|
949
|
+
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
950
|
+
return { byRequest, degraded, permanentFailures }
|
|
951
|
+
}
|
|
765
952
|
}
|
|
766
953
|
}
|
|
767
954
|
|
|
955
|
+
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
956
|
+
function replyPollFailure(err: unknown): string {
|
|
957
|
+
return err instanceof ApiCallError
|
|
958
|
+
? `${err.code} (HTTP ${err.status}): ${err.message}`
|
|
959
|
+
: String(err)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function permanentReplyFailureNote(failures: Map<string, string>): string | null {
|
|
963
|
+
if (failures.size === 0) return null
|
|
964
|
+
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ')
|
|
965
|
+
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`
|
|
966
|
+
}
|
|
967
|
+
|
|
768
968
|
/**
|
|
769
969
|
* Healthy companion devices that implement replies. Both the iOS app and the
|
|
770
970
|
* macOS app register reply categories and submit answers directly.
|
|
@@ -806,15 +1006,28 @@ function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefi
|
|
|
806
1006
|
*/
|
|
807
1007
|
export type RetireDeps = Pick<HookContext, 'client' | 'config'>
|
|
808
1008
|
|
|
809
|
-
/**
|
|
810
|
-
|
|
1009
|
+
/**
|
|
1010
|
+
* Close is a server-side serialization fence: a successful response contains
|
|
1011
|
+
* every reply that committed before the window closed. `null` is deliberately
|
|
1012
|
+
* different from an empty reply set — it means ownership could not be proven
|
|
1013
|
+
* final and the local record must stay recoverable.
|
|
1014
|
+
*/
|
|
1015
|
+
async function finalizeReplies(
|
|
1016
|
+
ctx: RetireDeps,
|
|
1017
|
+
requestId: string,
|
|
1018
|
+
): Promise<ListRepliesResponse | null> {
|
|
811
1019
|
try {
|
|
812
|
-
await ctx.client.closeReplies(requestId)
|
|
1020
|
+
return await ctx.client.closeReplies(requestId)
|
|
813
1021
|
} catch {
|
|
814
|
-
|
|
1022
|
+
return null
|
|
815
1023
|
}
|
|
816
1024
|
}
|
|
817
1025
|
|
|
1026
|
+
/** Best effort only for already-journaled retirement debt. */
|
|
1027
|
+
async function closeQuietly(ctx: RetireDeps, requestId: string): Promise<void> {
|
|
1028
|
+
await finalizeReplies(ctx, requestId)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
818
1031
|
/**
|
|
819
1032
|
* Retire the question on every device it reached. A state change is not news
|
|
820
1033
|
* (D-B): the retirement rides as a background state sync — no alert, no
|
|
@@ -1078,7 +1291,15 @@ export async function drainOrphanRetirements(
|
|
|
1078
1291
|
|
|
1079
1292
|
const LATE_STOP_POLL_SECONDS = 4
|
|
1080
1293
|
const LATE_PROMPT_POLL_SECONDS = 3
|
|
1081
|
-
|
|
1294
|
+
/**
|
|
1295
|
+
* The one bound on answer deliveries, for every route.
|
|
1296
|
+
*
|
|
1297
|
+
* It caps two things that both used to be able to run away: how many times a
|
|
1298
|
+
* single accepted answer may be handed to a route before the journal gives up
|
|
1299
|
+
* on it, and how many answer-driven continuation generations one chain may
|
|
1300
|
+
* produce.
|
|
1301
|
+
*/
|
|
1302
|
+
export const MAX_CONTINUATION_COUNT = 3
|
|
1082
1303
|
|
|
1083
1304
|
interface PendingPoll {
|
|
1084
1305
|
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
@@ -1086,7 +1307,7 @@ interface PendingPoll {
|
|
|
1086
1307
|
/** Every reply in arrival order, for free-text answers given in parts. */
|
|
1087
1308
|
replies: ReplyView[]
|
|
1088
1309
|
degraded: boolean
|
|
1089
|
-
|
|
1310
|
+
permanentFailure: string | null
|
|
1090
1311
|
}
|
|
1091
1312
|
|
|
1092
1313
|
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
@@ -1096,7 +1317,7 @@ async function pollPendingReply(
|
|
|
1096
1317
|
timeoutSeconds: number,
|
|
1097
1318
|
): Promise<PendingPoll> {
|
|
1098
1319
|
if (pending.request_id === undefined) {
|
|
1099
|
-
return { reply: null, replies: [], degraded: false,
|
|
1320
|
+
return { reply: null, replies: [], degraded: false, permanentFailure: null }
|
|
1100
1321
|
}
|
|
1101
1322
|
try {
|
|
1102
1323
|
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds)
|
|
@@ -1104,10 +1325,12 @@ async function pollPendingReply(
|
|
|
1104
1325
|
reply: result.replies.at(-1) ?? null,
|
|
1105
1326
|
replies: result.replies,
|
|
1106
1327
|
degraded: result.degraded === true,
|
|
1107
|
-
|
|
1328
|
+
permanentFailure: null,
|
|
1108
1329
|
}
|
|
1109
|
-
} catch {
|
|
1110
|
-
return
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
return isRetryableReplyPollError(err)
|
|
1332
|
+
? { reply: null, replies: [], degraded: true, permanentFailure: null }
|
|
1333
|
+
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) }
|
|
1111
1334
|
}
|
|
1112
1335
|
}
|
|
1113
1336
|
|
|
@@ -1118,6 +1341,49 @@ interface AnsweredPending {
|
|
|
1118
1341
|
replies: ReplyView[]
|
|
1119
1342
|
}
|
|
1120
1343
|
|
|
1344
|
+
interface FinalizedPending {
|
|
1345
|
+
pending: PendingQuestion
|
|
1346
|
+
response: ListRepliesResponse | null
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Close several windows concurrently without confusing failure with silence. */
|
|
1350
|
+
async function finalizePendings(
|
|
1351
|
+
ctx: HookContext,
|
|
1352
|
+
pending: PendingQuestion[],
|
|
1353
|
+
): Promise<FinalizedPending[]> {
|
|
1354
|
+
return Promise.all(
|
|
1355
|
+
pending.map(async (entry) => ({
|
|
1356
|
+
pending: entry,
|
|
1357
|
+
response: await finalizeReplies(ctx, entry.request_id!),
|
|
1358
|
+
})),
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
1363
|
+
function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
1364
|
+
const replies = finalized.response?.replies ?? []
|
|
1365
|
+
const reply = replies.at(-1)
|
|
1366
|
+
return reply === undefined ? null : { pending: finalized.pending, reply, replies }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** Persist and surface one answer without letting the two representations drift. */
|
|
1370
|
+
function reportAnswer(
|
|
1371
|
+
ctx: HookContext,
|
|
1372
|
+
notes: string[],
|
|
1373
|
+
answered: AnsweredPending,
|
|
1374
|
+
late: boolean,
|
|
1375
|
+
): void {
|
|
1376
|
+
ctx.log?.info('hook.answer', {
|
|
1377
|
+
answered: true,
|
|
1378
|
+
stage: 'queued',
|
|
1379
|
+
...(late ? { late: true } : {}),
|
|
1380
|
+
request_id: answered.pending.request_id,
|
|
1381
|
+
device: answered.reply.device_name,
|
|
1382
|
+
text: answered.reply.text,
|
|
1383
|
+
})
|
|
1384
|
+
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1121
1387
|
/**
|
|
1122
1388
|
* One bounded recovery poll across every delivered question, concurrently —
|
|
1123
1389
|
* N questions must not multiply the hook's latency budget by N.
|
|
@@ -1126,19 +1392,27 @@ async function pollPendingReplies(
|
|
|
1126
1392
|
ctx: HookContext,
|
|
1127
1393
|
live: PendingQuestion[],
|
|
1128
1394
|
timeoutSeconds: number,
|
|
1129
|
-
): Promise<{
|
|
1395
|
+
): Promise<{
|
|
1396
|
+
answered: AnsweredPending[]
|
|
1397
|
+
transientTrouble: boolean
|
|
1398
|
+
permanentFailures: Map<string, string>
|
|
1399
|
+
}> {
|
|
1130
1400
|
const polls = await Promise.all(
|
|
1131
1401
|
live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)),
|
|
1132
1402
|
)
|
|
1133
1403
|
const answered: AnsweredPending[] = []
|
|
1134
|
-
let
|
|
1404
|
+
let transientTrouble = false
|
|
1405
|
+
const permanentFailures = new Map<string, string>()
|
|
1135
1406
|
for (const [index, poll] of polls.entries()) {
|
|
1136
1407
|
if (poll.reply !== null) {
|
|
1137
1408
|
answered.push({ pending: live[index]!, reply: poll.reply, replies: poll.replies })
|
|
1138
1409
|
}
|
|
1139
|
-
if (poll.
|
|
1410
|
+
if (poll.degraded) transientTrouble = true
|
|
1411
|
+
if (poll.permanentFailure !== null) {
|
|
1412
|
+
permanentFailures.set(live[index]!.request_id!, poll.permanentFailure)
|
|
1413
|
+
}
|
|
1140
1414
|
}
|
|
1141
|
-
return { answered,
|
|
1415
|
+
return { answered, transientTrouble, permanentFailures }
|
|
1142
1416
|
}
|
|
1143
1417
|
|
|
1144
1418
|
/** The registered-question queue. Anything but the current shape reads as empty. */
|
|
@@ -1148,6 +1422,9 @@ function pendingList(state: SessionState): PendingQuestion[] {
|
|
|
1148
1422
|
|
|
1149
1423
|
/** Registration identity — the convention every racing writer compares by. */
|
|
1150
1424
|
function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
1425
|
+
if (a.question_id !== undefined || b.question_id !== undefined) {
|
|
1426
|
+
return a.question_id !== undefined && a.question_id === b.question_id
|
|
1427
|
+
}
|
|
1151
1428
|
return a.question === b.question && a.asked_at === b.asked_at
|
|
1152
1429
|
}
|
|
1153
1430
|
|
|
@@ -1167,18 +1444,28 @@ function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
|
1167
1444
|
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
1168
1445
|
* agent in the order it was written, so it can tell expansion from
|
|
1169
1446
|
* correction itself.
|
|
1447
|
+
*
|
|
1448
|
+
* The continuation repeats only identity the agent actually registered and
|
|
1449
|
+
* the answer the server accepted. It never invents trust, urgency, permission,
|
|
1450
|
+
* or approval claims for the transport to assert.
|
|
1170
1451
|
*/
|
|
1171
|
-
function answerContext(
|
|
1452
|
+
function answerContext(answered: AnsweredPending): string {
|
|
1453
|
+
const { pending, replies } = answered
|
|
1172
1454
|
const latest = replies.at(-1)
|
|
1173
1455
|
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
1174
|
-
|
|
1175
|
-
|
|
1456
|
+
const answeredQuestionIds = [...new Set(latest.answers.map((answer) => answer.question_id))]
|
|
1457
|
+
const identity =
|
|
1458
|
+
answeredQuestionIds.length === 0
|
|
1459
|
+
? ''
|
|
1460
|
+
: `question_id${answeredQuestionIds.length === 1 ? '' : 's'} ${answeredQuestionIds.join(', ')}, `
|
|
1461
|
+
const question = `question ${JSON.stringify(pending.question)}`
|
|
1462
|
+
if (replies.length === 1 || pendingHasChoices(pending)) {
|
|
1463
|
+
return `Notifai — ${identity}${question}; the user answered ${JSON.stringify(latest.text)}.`
|
|
1176
1464
|
}
|
|
1177
|
-
const parts = replies.map((reply) =>
|
|
1465
|
+
const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
|
|
1178
1466
|
return (
|
|
1179
|
-
`Notifai — the user answered
|
|
1180
|
-
`in the order written: ${parts}. Later parts extend or correct earlier ones
|
|
1181
|
-
'Continue with that answer.'
|
|
1467
|
+
`Notifai — ${identity}${question}; the user answered in ${replies.length} parts, ` +
|
|
1468
|
+
`in the order written: ${parts}. Later parts extend or correct earlier ones.`
|
|
1182
1469
|
)
|
|
1183
1470
|
}
|
|
1184
1471
|
|
|
@@ -1194,47 +1481,85 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
|
|
|
1194
1481
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
1195
1482
|
: ''
|
|
1196
1483
|
if (answered.length === 1) {
|
|
1197
|
-
|
|
1198
|
-
return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
|
|
1484
|
+
return answerContext(answered[0]!) + tail
|
|
1199
1485
|
}
|
|
1200
1486
|
const lines = answered.map(({ pending, replies }) => {
|
|
1201
1487
|
const latest = replies.at(-1)!
|
|
1202
1488
|
const answer =
|
|
1203
1489
|
replies.length === 1 || pendingHasChoices(pending)
|
|
1204
|
-
?
|
|
1205
|
-
: `${replies.map((reply) =>
|
|
1206
|
-
|
|
1490
|
+
? JSON.stringify(latest.text)
|
|
1491
|
+
: `${replies.map((reply) => JSON.stringify(reply.text)).join(', then ')} (parts in the order written; later parts extend or correct earlier ones)`
|
|
1492
|
+
const ids = [...new Set(latest.answers.map((entry) => entry.question_id))]
|
|
1493
|
+
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
|
|
1494
|
+
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
|
|
1207
1495
|
})
|
|
1208
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}
|
|
1496
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
|
|
1209
1497
|
}
|
|
1210
1498
|
|
|
1211
|
-
function
|
|
1499
|
+
function stopAnswerOutput(context: string): string {
|
|
1212
1500
|
return JSON.stringify({
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
additionalContext: context,
|
|
1216
|
-
},
|
|
1501
|
+
decision: 'block',
|
|
1502
|
+
reason: context,
|
|
1217
1503
|
})
|
|
1218
1504
|
}
|
|
1219
1505
|
|
|
1220
|
-
function stopAnswerOutput(context: string): string {
|
|
1221
|
-
return JSON.stringify({ decision: 'block', reason: context })
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
1506
|
/**
|
|
1225
1507
|
* Close, truthfully retire, and forget every answered pending question in one
|
|
1226
1508
|
* state write, without dropping any other retirement debt or any question
|
|
1227
1509
|
* still waiting. Stop answers also open one bounded continuation generation;
|
|
1228
1510
|
* UserPromptSubmit answers ride the user's new turn.
|
|
1229
1511
|
*/
|
|
1230
|
-
|
|
1512
|
+
function stageAcceptedAnswers(
|
|
1231
1513
|
ctx: HookContext,
|
|
1232
|
-
envelope: HookEnvelope,
|
|
1233
1514
|
sessionId: string,
|
|
1234
1515
|
answered: AnsweredPending[],
|
|
1235
|
-
|
|
1236
|
-
):
|
|
1237
|
-
const
|
|
1516
|
+
remaining: number,
|
|
1517
|
+
): AcceptedAnswerDelivery {
|
|
1518
|
+
const accepted: AcceptedAnswerDelivery = {
|
|
1519
|
+
answers: answered,
|
|
1520
|
+
remaining,
|
|
1521
|
+
recorded_at: ctx.now(),
|
|
1522
|
+
}
|
|
1523
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1524
|
+
const pendingRemaining = pendingList(current).filter(
|
|
1525
|
+
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1526
|
+
)
|
|
1527
|
+
const next: SessionState = {
|
|
1528
|
+
...current,
|
|
1529
|
+
accepted,
|
|
1530
|
+
}
|
|
1531
|
+
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
1532
|
+
else delete next.pending
|
|
1533
|
+
return next
|
|
1534
|
+
})
|
|
1535
|
+
return accepted
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** Amend the live accepted record in place, without resurrecting a settled one. */
|
|
1539
|
+
function amendAcceptedAnswers(
|
|
1540
|
+
ctx: HookContext,
|
|
1541
|
+
sessionId: string,
|
|
1542
|
+
amend: (accepted: AcceptedAnswerDelivery) => AcceptedAnswerDelivery,
|
|
1543
|
+
): void {
|
|
1544
|
+
updateSessionState(sessionId, ctx.env, (current) =>
|
|
1545
|
+
current.accepted === undefined ? current : { ...current, accepted: amend(current.accepted) },
|
|
1546
|
+
)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Retire the answered questions and close the journal.
|
|
1551
|
+
*
|
|
1552
|
+
* Called once an answer's delivery is acknowledged — by the route's own write,
|
|
1553
|
+
* or by the successor Stop of a blocking continuation. The continuation counter
|
|
1554
|
+
* grows with each settled generation, so the cap that bounds chained
|
|
1555
|
+
* answer-to-question loops is a real count rather than a constant 1.
|
|
1556
|
+
*/
|
|
1557
|
+
function settleAcceptedAnswers(
|
|
1558
|
+
ctx: HookContext,
|
|
1559
|
+
sessionId: string,
|
|
1560
|
+
accepted: AcceptedAnswerDelivery,
|
|
1561
|
+
): void {
|
|
1562
|
+
const retirements = accepted.answers
|
|
1238
1563
|
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1239
1564
|
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1240
1565
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
@@ -1244,24 +1569,44 @@ async function finishAnsweredPendings(
|
|
|
1244
1569
|
if (existing < 0) retiring.push(retirement)
|
|
1245
1570
|
else retiring[existing] = retirement
|
|
1246
1571
|
}
|
|
1247
|
-
const remaining = pendingList(current).filter(
|
|
1248
|
-
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1249
|
-
)
|
|
1250
1572
|
const next: SessionState = { ...current, retiring }
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1573
|
+
delete next.accepted
|
|
1574
|
+
next.continuation = {
|
|
1575
|
+
answered_at: accepted.recorded_at,
|
|
1576
|
+
count: (current.continuation?.count ?? 0) + 1,
|
|
1577
|
+
}
|
|
1578
|
+
return next
|
|
1579
|
+
})
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function retirePendings(
|
|
1583
|
+
ctx: HookContext,
|
|
1584
|
+
envelope: HookEnvelope,
|
|
1585
|
+
sessionId: string,
|
|
1586
|
+
entries: PendingQuestion[],
|
|
1587
|
+
state: LifecycleEndState,
|
|
1588
|
+
): Promise<void> {
|
|
1589
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1590
|
+
const retiring = [...(current.retiring ?? [])]
|
|
1591
|
+
for (const entry of entries) {
|
|
1592
|
+
const retirement = retiringQuestion(entry, state)
|
|
1593
|
+
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
1594
|
+
retiring.push(retirement)
|
|
1258
1595
|
}
|
|
1259
|
-
} else {
|
|
1260
|
-
next.last_prompt_at = ctx.now()
|
|
1261
1596
|
}
|
|
1597
|
+
const pending = pendingList(current).filter(
|
|
1598
|
+
(candidate) => !entries.some((entry) => isSamePending(candidate, entry)),
|
|
1599
|
+
)
|
|
1600
|
+
const next: SessionState = { ...current }
|
|
1601
|
+
if (pending.length > 0) next.pending = pending
|
|
1602
|
+
else delete next.pending
|
|
1603
|
+
if (retiring.length > 0) next.retiring = retiring
|
|
1604
|
+
else delete next.retiring
|
|
1262
1605
|
return next
|
|
1263
1606
|
})
|
|
1264
|
-
|
|
1607
|
+
// Network retirement is deliberately deferred to a later no-question hook.
|
|
1608
|
+
// The state write above is the durable operation; blocking here creates a
|
|
1609
|
+
// crash window before an answer can reach harness stdout.
|
|
1265
1610
|
}
|
|
1266
1611
|
|
|
1267
1612
|
// ---------------------------------------------------------------------------
|
|
@@ -1283,27 +1628,67 @@ export async function handleUserPromptSubmit(
|
|
|
1283
1628
|
if (!sessionId) return { notes }
|
|
1284
1629
|
|
|
1285
1630
|
const state = readSessionState(sessionId, ctx.env)
|
|
1631
|
+
if (state.accepted !== undefined) {
|
|
1632
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1633
|
+
...current,
|
|
1634
|
+
last_prompt_at: ctx.now(),
|
|
1635
|
+
}))
|
|
1636
|
+
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1637
|
+
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1638
|
+
}
|
|
1639
|
+
notes.push(
|
|
1640
|
+
state.accepted.delivered_at === undefined
|
|
1641
|
+
? 'a device answer is safely journaled; the next Stop will deliver it'
|
|
1642
|
+
: 'a device answer has already been delivered; the next Stop will close it out',
|
|
1643
|
+
)
|
|
1644
|
+
return { notes }
|
|
1645
|
+
}
|
|
1286
1646
|
const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
|
|
1287
1647
|
let lateAnswers: AnsweredPending[] = []
|
|
1288
1648
|
if (live.length > 0) {
|
|
1289
|
-
const { answered,
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1649
|
+
const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(
|
|
1650
|
+
ctx,
|
|
1651
|
+
live,
|
|
1652
|
+
LATE_PROMPT_POLL_SECONDS,
|
|
1653
|
+
)
|
|
1654
|
+
const finalized = await finalizePendings(
|
|
1655
|
+
ctx,
|
|
1656
|
+
answered.map((entry) => entry.pending),
|
|
1657
|
+
)
|
|
1658
|
+
lateAnswers = answered.map((entry) => {
|
|
1659
|
+
const response = finalized.find((candidate) =>
|
|
1660
|
+
isSamePending(candidate.pending, entry.pending),
|
|
1661
|
+
)?.response
|
|
1662
|
+
const replies = response?.replies.length ? response.replies : entry.replies
|
|
1663
|
+
if (response === null) {
|
|
1664
|
+
notes.push(
|
|
1665
|
+
`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
|
|
1666
|
+
)
|
|
1295
1667
|
}
|
|
1668
|
+
return { ...entry, replies, reply: replies.at(-1)! }
|
|
1669
|
+
})
|
|
1670
|
+
if (answered.length > 0) {
|
|
1671
|
+
stageAcceptedAnswers(
|
|
1672
|
+
ctx,
|
|
1673
|
+
sessionId,
|
|
1674
|
+
lateAnswers,
|
|
1675
|
+
0,
|
|
1676
|
+
)
|
|
1677
|
+
for (const answer of lateAnswers) reportAnswer(ctx, notes, answer, true)
|
|
1296
1678
|
}
|
|
1297
|
-
if (
|
|
1679
|
+
if (transientTrouble) {
|
|
1298
1680
|
notes.push('could not check every pending question for a late answer before the prompt')
|
|
1299
1681
|
}
|
|
1682
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
1683
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1300
1684
|
}
|
|
1301
1685
|
// Park before dropping `pending`. If the process dies between these writes,
|
|
1302
1686
|
// the next hook sees both copies and dedupes them; the old order could die in
|
|
1303
1687
|
// the gap after erasing the only request/collapse/device identifiers.
|
|
1304
1688
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1305
1689
|
const retiring = [...(current.retiring ?? [])]
|
|
1306
|
-
|
|
1690
|
+
const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
|
|
1691
|
+
for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
|
|
1307
1692
|
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
1308
1693
|
if (
|
|
1309
1694
|
retirement !== null &&
|
|
@@ -1312,7 +1697,21 @@ export async function handleUserPromptSubmit(
|
|
|
1312
1697
|
retiring.push(retirement)
|
|
1313
1698
|
}
|
|
1314
1699
|
}
|
|
1315
|
-
return {
|
|
1700
|
+
return {
|
|
1701
|
+
last_prompt_at: ctx.now(),
|
|
1702
|
+
...(current.last_stop_at === undefined ? {} : { last_stop_at: current.last_stop_at }),
|
|
1703
|
+
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1704
|
+
...(retiring.length > 0 ? { retiring } : {}),
|
|
1705
|
+
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1706
|
+
// The user typed, so whatever chain of answer-driven continuations was
|
|
1707
|
+
// running, a human has taken the turn and the consecutive count starts
|
|
1708
|
+
// over. Only a real prompt reaches here: the journal branch above returns
|
|
1709
|
+
// first, and a wake route's injected message always arrives while its own
|
|
1710
|
+
// answer is still journaled, so it can never pass for the user's presence.
|
|
1711
|
+
...(current.continuation === undefined
|
|
1712
|
+
? {}
|
|
1713
|
+
: { continuation: { ...current.continuation, count: 0 } }),
|
|
1714
|
+
}
|
|
1316
1715
|
})
|
|
1317
1716
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
1318
1717
|
// session: an agent shell command gets no hook payload, and not every
|
|
@@ -1321,17 +1720,19 @@ export async function handleUserPromptSubmit(
|
|
|
1321
1720
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1322
1721
|
}
|
|
1323
1722
|
|
|
1723
|
+
if (lateAnswers.length > 0) {
|
|
1724
|
+
// UserPromptSubmit has no later hook field proving its stdout reached the
|
|
1725
|
+
// model. Keep the journal and let the next Stop use its acknowledged
|
|
1726
|
+
// continuation channel instead of recreating the crash-before-stdout gap.
|
|
1727
|
+
notes.push('the late device answer will continue the agent at this turn’s Stop')
|
|
1728
|
+
return { notes }
|
|
1729
|
+
}
|
|
1324
1730
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
1325
1731
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1326
1732
|
const swept = [...retired, ...orphaned]
|
|
1327
1733
|
if (swept.length > 0) {
|
|
1328
1734
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1329
1735
|
}
|
|
1330
|
-
if (lateAnswers.length > 0) {
|
|
1331
|
-
// Whatever the user did not answer was just retired above — they are at
|
|
1332
|
-
// the keyboard now, so nothing is "still waiting" from their side.
|
|
1333
|
-
return { stdout: userPromptAnswerOutput(answersContext(lateAnswers, 0)), notes }
|
|
1334
|
-
}
|
|
1335
1736
|
return { notes }
|
|
1336
1737
|
}
|
|
1337
1738
|
|
|
@@ -1339,66 +1740,312 @@ export async function handleUserPromptSubmit(
|
|
|
1339
1740
|
// Stop — the turn ended; escalate a registered question
|
|
1340
1741
|
// ---------------------------------------------------------------------------
|
|
1341
1742
|
|
|
1342
|
-
/**
|
|
1343
|
-
* How long an escalated question keeps accepting an answer after the turn
|
|
1344
|
-
* ended. Long enough to survive a walk away from the desk, short enough that a
|
|
1345
|
-
* forgotten question does not resurface days later as a live prompt.
|
|
1346
|
-
*/
|
|
1347
|
-
const QUESTION_WINDOW_SECONDS = 3600
|
|
1348
|
-
|
|
1349
1743
|
/**
|
|
1350
1744
|
* Only engages for a question the agent explicitly registered with
|
|
1351
1745
|
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
1352
1746
|
* and it is not worth it: a false positive here hijacks the terminal.
|
|
1353
1747
|
*/
|
|
1354
|
-
export async function handleStop(
|
|
1355
|
-
|
|
1748
|
+
export async function handleStop(
|
|
1749
|
+
ctx: HookContext,
|
|
1750
|
+
envelope: HookEnvelope,
|
|
1751
|
+
processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000,
|
|
1752
|
+
route: EscalationDeliveryRoute = hookContinuationRoute(),
|
|
1753
|
+
): Promise<HookOutcome> {
|
|
1356
1754
|
const sessionId = envelope.session_id
|
|
1357
|
-
if (!sessionId)
|
|
1755
|
+
if (!sessionId) {
|
|
1756
|
+
gate(ctx, 'held', 'no-session', { stage: 'queued' })
|
|
1757
|
+
return { notes: [] }
|
|
1758
|
+
}
|
|
1759
|
+
return runEscalationWaiter(ctx, {
|
|
1760
|
+
sessionId,
|
|
1761
|
+
envelope,
|
|
1762
|
+
route,
|
|
1763
|
+
processDeadlineAt,
|
|
1764
|
+
})
|
|
1765
|
+
}
|
|
1358
1766
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
]
|
|
1368
|
-
|
|
1369
|
-
|
|
1767
|
+
/**
|
|
1768
|
+
* Own one registered question pipeline from its terminal-first timer through
|
|
1769
|
+
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
1770
|
+
*/
|
|
1771
|
+
export async function runEscalationWaiter(
|
|
1772
|
+
ctx: HookContext,
|
|
1773
|
+
options: EscalationWaiterOptions,
|
|
1774
|
+
): Promise<HookOutcome> {
|
|
1775
|
+
const notes: string[] = []
|
|
1776
|
+
const { sessionId, envelope } = options
|
|
1777
|
+
const hardDeadlineAt =
|
|
1778
|
+
options.processDeadlineAt ?? ctx.now() + WAITER_CEILING_SECONDS * 1000
|
|
1779
|
+
|
|
1780
|
+
// This event marker is diagnostic evidence in its own right. Record it
|
|
1781
|
+
// before every early return so doctor can distinguish a working Stop route
|
|
1782
|
+
// from the prompt-only state that previously looked healthy.
|
|
1783
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1784
|
+
...current,
|
|
1785
|
+
last_stop_at: ctx.now(),
|
|
1786
|
+
}))
|
|
1787
|
+
|
|
1788
|
+
// The waiter, not its host, owns this lease. It spans grace, submission,
|
|
1789
|
+
// polling, close fencing, route delivery, and every retirement path.
|
|
1790
|
+
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
1791
|
+
// another process is alive right now, which an injected clock cannot know.
|
|
1792
|
+
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1793
|
+
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
|
|
1794
|
+
notes.push('another hook is already handling this question')
|
|
1795
|
+
return { notes }
|
|
1370
1796
|
}
|
|
1797
|
+
let settledAnswerThisPass = false
|
|
1798
|
+
try {
|
|
1799
|
+
let state = readSessionState(sessionId, ctx.env)
|
|
1800
|
+
if (state.accepted !== undefined) {
|
|
1801
|
+
const accepted = state.accepted
|
|
1802
|
+
// Acknowledgement is per route, because routes end in different places.
|
|
1803
|
+
//
|
|
1804
|
+
// A blocking continuation ends at this process's stdout, which the
|
|
1805
|
+
// harness reads only after the process exits: the recursive Stop's
|
|
1806
|
+
// `stop_hook_active` is the acknowledgement, and persisting a separate
|
|
1807
|
+
// "offered" bit before stdout would recreate the crash gap this journal
|
|
1808
|
+
// exists to close. An out-of-band route ends at a write this process made
|
|
1809
|
+
// itself and recorded here. A socket write is not proof the model acted
|
|
1810
|
+
// on the answer — delivery is not consumption — but nothing later can
|
|
1811
|
+
// prove more, and an answer redelivered without end is strictly worse
|
|
1812
|
+
// than one settled on delivery. So a recorded delivery settles.
|
|
1813
|
+
const deliveryProven =
|
|
1814
|
+
accepted.delivered_at !== undefined || envelope.stop_hook_active === true
|
|
1815
|
+
if (!deliveryProven) {
|
|
1816
|
+
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
1817
|
+
return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
|
|
1818
|
+
}
|
|
1819
|
+
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1820
|
+
settledAnswerThisPass = true
|
|
1821
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1822
|
+
}
|
|
1371
1823
|
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1824
|
+
const pending = pendingList(state)
|
|
1825
|
+
if (pending.length === 0) {
|
|
1826
|
+
const swept = [
|
|
1827
|
+
...(await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))),
|
|
1828
|
+
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
1829
|
+
]
|
|
1830
|
+
if (swept.length > 0) {
|
|
1831
|
+
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1832
|
+
}
|
|
1833
|
+
gate(ctx, 'held', 'no-question', { stage: 'queued' })
|
|
1834
|
+
return { notes }
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
return await handleClaimedStop(
|
|
1838
|
+
ctx,
|
|
1839
|
+
envelope,
|
|
1840
|
+
sessionId,
|
|
1841
|
+
state,
|
|
1842
|
+
pending,
|
|
1843
|
+
notes,
|
|
1844
|
+
hardDeadlineAt,
|
|
1845
|
+
options.route,
|
|
1846
|
+
// Every route's successor turn-end, named the same way: the harness flag
|
|
1847
|
+
// for a blocking continuation, and the settle above for a route that woke
|
|
1848
|
+
// a brand-new turn out of band.
|
|
1849
|
+
envelope.stop_hook_active === true || settledAnswerThisPass,
|
|
1850
|
+
)
|
|
1851
|
+
} finally {
|
|
1852
|
+
releaseQuestionPush(sessionId, ctx.env)
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* The blocking Stop hook's own last meter: the answer becomes this process's
|
|
1858
|
+
* stdout, and the harness admits it as a new user turn. Exported because a
|
|
1859
|
+
* host adapter that has other routes still falls back to this one.
|
|
1860
|
+
*/
|
|
1861
|
+
export function hookContinuationRoute(): EscalationDeliveryRoute {
|
|
1862
|
+
return {
|
|
1863
|
+
kind: 'hook-continuation',
|
|
1864
|
+
deliver: async (event) => ({
|
|
1865
|
+
stdout: stopAnswerOutput(event.context),
|
|
1866
|
+
notes: [],
|
|
1867
|
+
// The harness reads this stdout after the process exits, so only the
|
|
1868
|
+
// successor Stop can acknowledge it.
|
|
1869
|
+
acknowledgement: 'stdout',
|
|
1870
|
+
}),
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Hand one accepted answer to a route and record what the attempt proved.
|
|
1876
|
+
*
|
|
1877
|
+
* Every delivery on every route passes through here, which is what makes the
|
|
1878
|
+
* cap below a backstop rather than a suggestion. A guard only one route
|
|
1879
|
+
* consults is not a backstop: that is how a single answer once reached a
|
|
1880
|
+
* session 250 times.
|
|
1881
|
+
*/
|
|
1882
|
+
async function deliverAcceptedAnswers(
|
|
1883
|
+
ctx: HookContext,
|
|
1884
|
+
sessionId: string,
|
|
1885
|
+
route: EscalationDeliveryRoute,
|
|
1886
|
+
accepted: AcceptedAnswerDelivery,
|
|
1887
|
+
notes: string[],
|
|
1888
|
+
): Promise<HookOutcome> {
|
|
1889
|
+
const { answers: answered, remaining } = accepted
|
|
1890
|
+
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
1891
|
+
const attempt = (accepted.delivery_attempts ?? 0) + 1
|
|
1892
|
+
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
1893
|
+
gate(ctx, 'held', 'delivery-limit', {
|
|
1894
|
+
route: route.kind,
|
|
1895
|
+
attempts: accepted.delivery_attempts ?? 0,
|
|
1896
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
1897
|
+
request_ids: requestIds,
|
|
1898
|
+
})
|
|
1899
|
+
// Settle rather than journal for ever. The answer has been offered as often
|
|
1900
|
+
// as any working route could need, it is already in the notes of each of
|
|
1901
|
+
// those turns, and the questions it answered still deserve retirement.
|
|
1902
|
+
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1903
|
+
notes.push(
|
|
1904
|
+
`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
|
|
1905
|
+
)
|
|
1906
|
+
return { notes }
|
|
1907
|
+
}
|
|
1908
|
+
// Count the attempt before making it: an attempt that dies mid-delivery still
|
|
1909
|
+
// happened, and a counter written afterwards could be evaded by crashing.
|
|
1910
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1911
|
+
...current,
|
|
1912
|
+
delivery_attempts: attempt,
|
|
1913
|
+
}))
|
|
1914
|
+
ctx.log?.info('hook.gate', {
|
|
1915
|
+
verdict: 'proceeding',
|
|
1916
|
+
reason: 'answered',
|
|
1917
|
+
stage: 'routed',
|
|
1918
|
+
route: route.kind,
|
|
1919
|
+
answers: answered.length,
|
|
1920
|
+
request_ids: requestIds,
|
|
1921
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1922
|
+
delivery_attempt: attempt,
|
|
1923
|
+
})
|
|
1924
|
+
const delivered = await route.deliver({
|
|
1925
|
+
context: answersContext(answered, remaining),
|
|
1926
|
+
answers: answered.length,
|
|
1927
|
+
remaining,
|
|
1928
|
+
request_ids: requestIds,
|
|
1929
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1930
|
+
})
|
|
1931
|
+
const deliveredRoute =
|
|
1932
|
+
typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind
|
|
1933
|
+
const deliveredStage =
|
|
1934
|
+
typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered'
|
|
1935
|
+
if (delivered.acknowledgement === 'delivered') {
|
|
1936
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1937
|
+
...current,
|
|
1938
|
+
delivered_at: ctx.now(),
|
|
1939
|
+
delivered_route: deliveredRoute,
|
|
1940
|
+
}))
|
|
1941
|
+
}
|
|
1942
|
+
ctx.log?.info('hook.answer', {
|
|
1943
|
+
answered: true,
|
|
1944
|
+
stage: deliveredStage,
|
|
1945
|
+
route: deliveredRoute,
|
|
1946
|
+
acknowledgement: delivered.acknowledgement,
|
|
1947
|
+
delivery_attempt: attempt,
|
|
1948
|
+
request_ids: requestIds,
|
|
1949
|
+
answers: answered.length,
|
|
1950
|
+
journal_recorded_at: accepted.recorded_at,
|
|
1951
|
+
...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
|
|
1952
|
+
})
|
|
1953
|
+
const outcome: HookOutcome = { notes: [...notes, ...(delivered.notes ?? [])] }
|
|
1954
|
+
if (delivered.stdout !== undefined) outcome.stdout = delivered.stdout
|
|
1955
|
+
if (delivered.log !== undefined) outcome.log = delivered.log
|
|
1956
|
+
return outcome
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1960
|
+
async function handleClaimedStop(
|
|
1961
|
+
ctx: HookContext,
|
|
1962
|
+
envelope: HookEnvelope,
|
|
1963
|
+
sessionId: string,
|
|
1964
|
+
state: SessionState,
|
|
1965
|
+
pending: PendingQuestion[],
|
|
1966
|
+
notes: string[],
|
|
1967
|
+
hardDeadlineAt: number,
|
|
1968
|
+
route: EscalationDeliveryRoute,
|
|
1969
|
+
continuingFromAnswer: boolean,
|
|
1970
|
+
): Promise<HookOutcome> {
|
|
1375
1971
|
const live = pending.filter((entry) => entry.request_id !== undefined)
|
|
1376
1972
|
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
1973
|
+
let liveToEscalate = live
|
|
1974
|
+
|
|
1975
|
+
if (
|
|
1976
|
+
ctx.harness !== undefined &&
|
|
1977
|
+
HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported'
|
|
1978
|
+
) {
|
|
1979
|
+
for (const entry of live) await closeQuietly(ctx, entry.request_id!)
|
|
1980
|
+
await retirePendings(ctx, envelope, sessionId, pending, 'expired')
|
|
1981
|
+
gate(ctx, 'held', 'no-question')
|
|
1982
|
+
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
|
|
1983
|
+
return { notes }
|
|
1984
|
+
}
|
|
1377
1985
|
|
|
1378
1986
|
if (live.length > 0) {
|
|
1379
|
-
const { answered,
|
|
1987
|
+
const { answered, permanentFailures } = await pollPendingReplies(
|
|
1988
|
+
ctx,
|
|
1989
|
+
live,
|
|
1990
|
+
LATE_STOP_POLL_SECONDS,
|
|
1991
|
+
)
|
|
1992
|
+
const permanentFailure = permanentReplyFailureNote(permanentFailures)
|
|
1993
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1994
|
+
const permanentlyRejected = live.filter((entry) =>
|
|
1995
|
+
permanentFailures.has(entry.request_id!),
|
|
1996
|
+
)
|
|
1997
|
+
await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id!)))
|
|
1998
|
+
if (permanentlyRejected.length > 0) {
|
|
1999
|
+
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired')
|
|
2000
|
+
}
|
|
1380
2001
|
if (answered.length > 0) {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2002
|
+
const finalized = await finalizePendings(
|
|
2003
|
+
ctx,
|
|
2004
|
+
answered.map((entry) => entry.pending),
|
|
2005
|
+
)
|
|
2006
|
+
const authoritative = answered.map((entry) => {
|
|
2007
|
+
const response = finalized.find((candidate) =>
|
|
2008
|
+
isSamePending(candidate.pending, entry.pending),
|
|
2009
|
+
)?.response
|
|
2010
|
+
const replies = response?.replies.length ? response.replies : entry.replies
|
|
2011
|
+
if (response === null) {
|
|
2012
|
+
notes.push(
|
|
2013
|
+
`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
|
|
2014
|
+
)
|
|
2015
|
+
}
|
|
2016
|
+
return { ...entry, replies, reply: replies.at(-1)! }
|
|
2017
|
+
})
|
|
2018
|
+
const accepted = stageAcceptedAnswers(
|
|
2019
|
+
ctx,
|
|
2020
|
+
sessionId,
|
|
2021
|
+
authoritative,
|
|
2022
|
+
pending.length - authoritative.length - permanentlyRejected.length,
|
|
2023
|
+
)
|
|
2024
|
+
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
1385
2025
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1386
2026
|
// with answers right now, and may not even need the rest afterwards.
|
|
1387
|
-
return
|
|
1388
|
-
stdout: stopAnswerOutput(answersContext(answered, pending.length - answered.length)),
|
|
1389
|
-
notes,
|
|
1390
|
-
}
|
|
2027
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
1391
2028
|
}
|
|
2029
|
+
const recoverableLive = live.filter(
|
|
2030
|
+
(entry) => !permanentFailures.has(entry.request_id!),
|
|
2031
|
+
)
|
|
2032
|
+
liveToEscalate = recoverableLive
|
|
1392
2033
|
if (unasked.length === 0) {
|
|
1393
|
-
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
2034
|
+
if (recoverableLive.length === 0) return { notes }
|
|
2035
|
+
// A previous answer may have resumed the model while independent
|
|
2036
|
+
// questions remain. The next Stop is their successor owner: keep waiting
|
|
2037
|
+
// to the original absolute deadline instead of polling for four seconds
|
|
2038
|
+
// and abandoning them.
|
|
2039
|
+
return await escalate(
|
|
2040
|
+
ctx,
|
|
2041
|
+
envelope,
|
|
2042
|
+
sessionId,
|
|
2043
|
+
[],
|
|
2044
|
+
recoverableLive,
|
|
2045
|
+
notes,
|
|
2046
|
+
hardDeadlineAt,
|
|
2047
|
+
route,
|
|
1400
2048
|
)
|
|
1401
|
-
return { notes }
|
|
1402
2049
|
}
|
|
1403
2050
|
// Questions registered after the earlier push still owe the user their
|
|
1404
2051
|
// notification; fall through and escalate just those.
|
|
@@ -1415,36 +2062,54 @@ export async function handleStop(ctx: HookContext, envelope: HookEnvelope): Prom
|
|
|
1415
2062
|
(entry) => entry.asked_at !== undefined && entry.asked_at > continuation.answered_at,
|
|
1416
2063
|
)
|
|
1417
2064
|
if (!isNew) {
|
|
2065
|
+
gate(ctx, 'held', 'continuation-repeat')
|
|
1418
2066
|
notes.push('already continuing from an answer; not asking again this turn')
|
|
1419
2067
|
return { notes }
|
|
1420
2068
|
}
|
|
1421
|
-
if (continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1422
|
-
notes.push(
|
|
1423
|
-
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
1424
|
-
)
|
|
1425
|
-
return { notes }
|
|
1426
|
-
}
|
|
1427
2069
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
2070
|
+
// The cap is deliberately not keyed to `stop_hook_active`. That flag names one
|
|
2071
|
+
// route's continuation; a route that wakes a brand-new turn never sets it, and
|
|
2072
|
+
// a limit only one route consults bounds only that route. `continuingFromAnswer`
|
|
2073
|
+
// is the same event named for every route, so the chain of answer → follow-up
|
|
2074
|
+
// question → answer is bounded whatever delivered it.
|
|
2075
|
+
if (
|
|
2076
|
+
continuingFromAnswer &&
|
|
2077
|
+
state.continuation !== undefined &&
|
|
2078
|
+
state.continuation.count >= MAX_CONTINUATION_COUNT
|
|
2079
|
+
) {
|
|
2080
|
+
gate(ctx, 'held', 'continuation-limit', {
|
|
2081
|
+
count: state.continuation.count,
|
|
2082
|
+
limit: MAX_CONTINUATION_COUNT,
|
|
2083
|
+
route: route.kind,
|
|
2084
|
+
})
|
|
2085
|
+
notes.push(
|
|
2086
|
+
`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`,
|
|
2087
|
+
)
|
|
1431
2088
|
return { notes }
|
|
1432
2089
|
}
|
|
1433
|
-
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1440
|
-
notes.push('another hook is already handling this question')
|
|
2090
|
+
// Silent to the user by design — they switched routing off, so saying so on
|
|
2091
|
+
// every turn would be nagging about their own setting. That silence is also
|
|
2092
|
+
// why it belongs in the log: from outside, "ask_notifications = false" and "a
|
|
2093
|
+
// bug ate my question" look exactly the same.
|
|
2094
|
+
if (!ctx.config.ask_notifications.value) {
|
|
2095
|
+
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
|
|
1441
2096
|
return { notes }
|
|
1442
2097
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
}
|
|
2098
|
+
gate(ctx, 'proceeding', 'proceeding', {
|
|
2099
|
+
unasked: unasked.length,
|
|
2100
|
+
already_live: liveToEscalate.length,
|
|
2101
|
+
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
2102
|
+
})
|
|
2103
|
+
return await escalate(
|
|
2104
|
+
ctx,
|
|
2105
|
+
envelope,
|
|
2106
|
+
sessionId,
|
|
2107
|
+
unasked,
|
|
2108
|
+
liveToEscalate,
|
|
2109
|
+
notes,
|
|
2110
|
+
hardDeadlineAt,
|
|
2111
|
+
route,
|
|
2112
|
+
)
|
|
1448
2113
|
}
|
|
1449
2114
|
|
|
1450
2115
|
/** The escalation itself, split out so the claim is released on every path. */
|
|
@@ -1455,137 +2120,329 @@ async function escalate(
|
|
|
1455
2120
|
unasked: PendingQuestion[],
|
|
1456
2121
|
alreadyLive: PendingQuestion[],
|
|
1457
2122
|
notes: string[],
|
|
2123
|
+
hardDeadlineAt: number,
|
|
2124
|
+
route: EscalationDeliveryRoute,
|
|
1458
2125
|
): Promise<HookOutcome> {
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
1462
|
-
const
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2126
|
+
// The questions still owe the user their terminal-first window before
|
|
2127
|
+
// anything reaches their devices — measured from the oldest registration,
|
|
2128
|
+
// because that is the question that has waited longest.
|
|
2129
|
+
const startedAt = ctx.now()
|
|
2130
|
+
const existingDeadlines = alreadyLive.flatMap((entry) =>
|
|
2131
|
+
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
|
|
2132
|
+
? []
|
|
2133
|
+
: [entry.reply_deadline_at],
|
|
2134
|
+
)
|
|
2135
|
+
const ceilingAt = Math.min(
|
|
2136
|
+
hardDeadlineAt,
|
|
2137
|
+
...(existingDeadlines.length === 0 ? [] : existingDeadlines),
|
|
2138
|
+
)
|
|
2139
|
+
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
2140
|
+
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
2141
|
+
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
|
|
2142
|
+
ctx.log?.debug('hook.gate', {
|
|
2143
|
+
verdict: 'grace',
|
|
2144
|
+
reason: 'elapsed',
|
|
2145
|
+
waited_from: oldest,
|
|
2146
|
+
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
2147
|
+
})
|
|
1470
2148
|
}
|
|
1471
|
-
|
|
1472
2149
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1473
2150
|
// its own notification — one ask never stands in for another.
|
|
1474
2151
|
const submitted: PendingQuestion[] = []
|
|
1475
2152
|
for (const entry of unasked) {
|
|
2153
|
+
// The reply window is the question's own: whatever is left of the waiter's
|
|
2154
|
+
// ceiling, granted to the server so it stops accepting answers at the same
|
|
2155
|
+
// moment this owner stops listening for them.
|
|
2156
|
+
const replyWindowSeconds = Math.floor((ceilingAt - ctx.now()) / 1000)
|
|
2157
|
+
if (replyWindowSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
2158
|
+
notes.push(
|
|
2159
|
+
'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
|
|
2160
|
+
)
|
|
2161
|
+
continue
|
|
2162
|
+
}
|
|
2163
|
+
const ownerDeadlineAt = ceilingAt
|
|
1476
2164
|
const questions = pendingQuestions(entry)
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2165
|
+
let intent = entry.submission
|
|
2166
|
+
if (intent === undefined) {
|
|
2167
|
+
const prepared = await prepareQuestionSubmission(ctx, {
|
|
2168
|
+
title:
|
|
2169
|
+
ctx.config.project.value === null
|
|
2170
|
+
? 'Question'
|
|
2171
|
+
: `Question · ${ctx.config.project.value}`,
|
|
2172
|
+
// A set is answered on the expanded card; the banner leads with the
|
|
2173
|
+
// first question and says how much more is waiting behind it.
|
|
2174
|
+
body:
|
|
2175
|
+
questions.length > 1
|
|
2176
|
+
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
2177
|
+
: entry.question,
|
|
2178
|
+
questions,
|
|
2179
|
+
...(entry.detail !== undefined ? { detail: entry.detail } : {}),
|
|
2180
|
+
event: 'agent_question',
|
|
2181
|
+
session: sessionLabel(ctx, envelope),
|
|
2182
|
+
windowSeconds: replyWindowSeconds,
|
|
2183
|
+
ownerDeadlineAt,
|
|
2184
|
+
})
|
|
2185
|
+
if ('error' in prepared) {
|
|
2186
|
+
ctx.log?.error('hook.pushed', { ok: false, message: prepared.error })
|
|
2187
|
+
notes.push(prepared.error)
|
|
2188
|
+
continue
|
|
2189
|
+
}
|
|
2190
|
+
intent = prepared
|
|
2191
|
+
// Durable before submit. If the server commits and the response is lost,
|
|
2192
|
+
// the reserved request id still lets this owner poll and finalize the
|
|
2193
|
+
// exact card; a successor can also replay the frozen draft/key.
|
|
2194
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2195
|
+
const list = pendingList(current)
|
|
2196
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2197
|
+
if (index < 0) return current
|
|
2198
|
+
const next = [...list]
|
|
2199
|
+
next[index] = { ...next[index]!, submission: prepared }
|
|
2200
|
+
return { ...current, pending: next }
|
|
2201
|
+
})
|
|
2202
|
+
} else if (intent.owner_deadline_at <= ctx.now()) {
|
|
2203
|
+
// The frozen wire identity survives a crashed owner, but its local owner
|
|
2204
|
+
// lease does not. Re-arm only the local deadline; request id, key,
|
|
2205
|
+
// targets, and draft remain byte-identical for idempotent replay.
|
|
2206
|
+
intent = { ...intent, owner_deadline_at: ceilingAt }
|
|
2207
|
+
const rearmed = intent
|
|
2208
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2209
|
+
const list = pendingList(current)
|
|
2210
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2211
|
+
if (index < 0) return current
|
|
2212
|
+
const next = [...list]
|
|
2213
|
+
next[index] = { ...next[index]!, submission: rearmed }
|
|
2214
|
+
return { ...current, pending: next }
|
|
2215
|
+
})
|
|
2216
|
+
}
|
|
2217
|
+
if (ctx.now() >= intent.owner_deadline_at) {
|
|
2218
|
+
notes.push('the waiter ceiling ended before submission; preserving the frozen intent')
|
|
2219
|
+
continue
|
|
2220
|
+
}
|
|
2221
|
+
const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
|
|
2222
|
+
if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
2223
|
+
notes.push(
|
|
2224
|
+
'device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts',
|
|
2225
|
+
)
|
|
1498
2226
|
continue
|
|
1499
2227
|
}
|
|
2228
|
+
let receipt: SubmissionReceipt | undefined
|
|
2229
|
+
let admissionConfirmed = false
|
|
2230
|
+
try {
|
|
2231
|
+
receipt = await submitQuestion(ctx, intent)
|
|
2232
|
+
admissionConfirmed = true
|
|
2233
|
+
} catch (err) {
|
|
2234
|
+
if (err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
2235
|
+
// A concrete client response is an admission rejection, not an
|
|
2236
|
+
// ambiguous commit. Keep the frozen intent for a later retry and leave
|
|
2237
|
+
// the question in the terminal; polling a request the server says it
|
|
2238
|
+
// did not accept only burns the owner window and hides the real fault.
|
|
2239
|
+
notes.push(
|
|
2240
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
|
|
2241
|
+
)
|
|
2242
|
+
ctx.log?.error('hook.pushed', {
|
|
2243
|
+
ok: false,
|
|
2244
|
+
request_id: intent.request_id,
|
|
2245
|
+
status: err.status,
|
|
2246
|
+
code: err.code,
|
|
2247
|
+
message: err.message,
|
|
2248
|
+
})
|
|
2249
|
+
continue
|
|
2250
|
+
}
|
|
2251
|
+
// This is deliberately not a fail-open return. A transport failure is
|
|
2252
|
+
// ambiguous: the server may already have dispatched the card. The
|
|
2253
|
+
// client-generated request id keeps polling/finalization possible.
|
|
2254
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
|
|
2255
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2256
|
+
}
|
|
2257
|
+
if (admissionConfirmed) {
|
|
2258
|
+
ctx.log?.info('hook.pushed', {
|
|
2259
|
+
ok: true,
|
|
2260
|
+
request_id: intent.request_id,
|
|
2261
|
+
devices: intent.device_ids.length,
|
|
2262
|
+
questions: questions.length,
|
|
2263
|
+
text: questions[0]!.text,
|
|
2264
|
+
})
|
|
2265
|
+
}
|
|
2266
|
+
const committedReplyDeadline =
|
|
2267
|
+
receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
2268
|
+
? intent.owner_deadline_at
|
|
2269
|
+
: Date.parse(receipt.reply_expires_at)
|
|
1500
2270
|
const live: PendingQuestion = {
|
|
1501
2271
|
...entry,
|
|
1502
|
-
request_id:
|
|
1503
|
-
collapse_key:
|
|
1504
|
-
device_ids:
|
|
2272
|
+
request_id: intent.request_id,
|
|
2273
|
+
collapse_key: intent.collapse_key,
|
|
2274
|
+
device_ids: intent.device_ids,
|
|
2275
|
+
// Submission and every later poll share one wall-clock owner. The
|
|
2276
|
+
// server may start its relative window a little later while the submit
|
|
2277
|
+
// response is in flight; closing at this deadline is therefore the
|
|
2278
|
+
// authoritative backstop against accepting an answer into a void.
|
|
2279
|
+
reply_deadline_at: Math.min(
|
|
2280
|
+
intent.owner_deadline_at,
|
|
2281
|
+
Number.isFinite(committedReplyDeadline)
|
|
2282
|
+
? committedReplyDeadline
|
|
2283
|
+
: intent.owner_deadline_at,
|
|
2284
|
+
),
|
|
1505
2285
|
}
|
|
2286
|
+
delete live.submission
|
|
1506
2287
|
submitted.push(live)
|
|
1507
2288
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
1508
2289
|
// only learned these ids afterwards, a question that timed out would
|
|
1509
2290
|
// leave no trace, and the user returning to the terminal could never
|
|
1510
2291
|
// retire it — the notification would stay answerable for an hour with
|
|
1511
2292
|
// nobody listening.
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
retiring.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
2293
|
+
if (admissionConfirmed) {
|
|
2294
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2295
|
+
const list = pendingList(current)
|
|
2296
|
+
const index = list.findIndex(
|
|
2297
|
+
(candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined,
|
|
2298
|
+
)
|
|
2299
|
+
if (index >= 0) {
|
|
2300
|
+
const next = [...list]
|
|
2301
|
+
next[index] = live
|
|
2302
|
+
return { ...current, pending: next }
|
|
2303
|
+
}
|
|
2304
|
+
// The entry vanished while the submit was in flight (the user's prompt
|
|
2305
|
+
// wiped the queue). The delivered notification must still be retirable,
|
|
2306
|
+
// so park it rather than lose its only identifiers.
|
|
2307
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere')!
|
|
2308
|
+
const retiring = [...(current.retiring ?? [])]
|
|
2309
|
+
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
2310
|
+
retiring.push(retirement)
|
|
2311
|
+
}
|
|
2312
|
+
return { ...current, retiring }
|
|
2313
|
+
})
|
|
2314
|
+
}
|
|
1532
2315
|
}
|
|
1533
2316
|
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
2317
|
+
const staleLive = alreadyLive.filter(
|
|
2318
|
+
(entry) =>
|
|
2319
|
+
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now(),
|
|
2320
|
+
)
|
|
2321
|
+
const finalizedStale = await finalizePendings(ctx, staleLive)
|
|
2322
|
+
const staleAnswers = finalizedStale
|
|
2323
|
+
.map(finalizedAnswer)
|
|
2324
|
+
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
2325
|
+
const staleSilence = finalizedStale
|
|
2326
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
2327
|
+
.map((entry) => entry.pending)
|
|
2328
|
+
const staleUnproven = finalizedStale.filter((entry) => entry.response === null)
|
|
2329
|
+
if (staleSilence.length > 0) {
|
|
2330
|
+
await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired')
|
|
1538
2331
|
notes.push(
|
|
1539
|
-
`question${
|
|
2332
|
+
`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`,
|
|
1540
2333
|
)
|
|
1541
|
-
return { notes }
|
|
1542
2334
|
}
|
|
2335
|
+
if (staleUnproven.length > 0) {
|
|
2336
|
+
notes.push(
|
|
2337
|
+
`could not finalize ${staleUnproven.length} expired question${staleUnproven.length === 1 ? '' : 's'}; preserving ownership for recovery`,
|
|
2338
|
+
)
|
|
2339
|
+
}
|
|
2340
|
+
if (staleAnswers.length > 0) {
|
|
2341
|
+
const remaining = Math.max(
|
|
2342
|
+
0,
|
|
2343
|
+
pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
|
|
2344
|
+
)
|
|
2345
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2346
|
+
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2347
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2348
|
+
}
|
|
2349
|
+
const waitingOn = [
|
|
2350
|
+
...alreadyLive.filter(
|
|
2351
|
+
(entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now(),
|
|
2352
|
+
),
|
|
2353
|
+
...submitted,
|
|
2354
|
+
]
|
|
2355
|
+
if (waitingOn.length === 0) return { notes }
|
|
1543
2356
|
|
|
1544
|
-
// Phase two: one
|
|
1545
|
-
const
|
|
1546
|
-
|
|
2357
|
+
// Phase two: one wait across everything live, old and new alike.
|
|
2358
|
+
const ownerDeadline = Math.min(
|
|
2359
|
+
ceilingAt,
|
|
2360
|
+
Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
|
|
2361
|
+
)
|
|
2362
|
+
const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
|
|
2363
|
+
const waited = await waitForAnyReply(
|
|
1547
2364
|
ctx,
|
|
1548
2365
|
waitingOn.map((entry) => entry.request_id!),
|
|
1549
2366
|
timeoutSeconds,
|
|
1550
2367
|
)
|
|
2368
|
+
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
2369
|
+
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
1551
2370
|
|
|
1552
2371
|
if (waited.byRequest.size === 0) {
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
const
|
|
1557
|
-
|
|
2372
|
+
// Once this owner returns, no shipped command-hook harness can guarantee
|
|
2373
|
+
// a later answer reaches the same agent turn. Close first, then retire the
|
|
2374
|
+
// local records, so the Companion never accepts an answer into a void.
|
|
2375
|
+
const finalized = await finalizePendings(ctx, waitingOn)
|
|
2376
|
+
const finalAnswers = finalized
|
|
2377
|
+
.map(finalizedAnswer)
|
|
2378
|
+
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
2379
|
+
const confirmedSilent = finalized
|
|
2380
|
+
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
2381
|
+
.map((entry) => entry.pending)
|
|
2382
|
+
const unproven = finalized.filter((entry) => entry.response === null)
|
|
2383
|
+
if (confirmedSilent.length > 0) {
|
|
2384
|
+
await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
|
|
2385
|
+
}
|
|
2386
|
+
if (unproven.length > 0) {
|
|
1558
2387
|
notes.push(
|
|
1559
|
-
`
|
|
1560
|
-
|
|
2388
|
+
`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`,
|
|
2389
|
+
)
|
|
2390
|
+
}
|
|
2391
|
+
if (finalAnswers.length > 0) {
|
|
2392
|
+
const remaining = Math.max(
|
|
2393
|
+
0,
|
|
2394
|
+
pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
|
|
1561
2395
|
)
|
|
1562
|
-
|
|
2396
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2397
|
+
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
2398
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2399
|
+
}
|
|
2400
|
+
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
2401
|
+
ctx.log?.info('hook.answer', {
|
|
2402
|
+
answered: false,
|
|
2403
|
+
stage: 'queued',
|
|
2404
|
+
request_ids: requestIdSummary.ids,
|
|
2405
|
+
degraded: waited.degraded,
|
|
2406
|
+
permanent_failures: waited.permanentFailures.size,
|
|
2407
|
+
waited_seconds: timeoutSeconds,
|
|
2408
|
+
})
|
|
2409
|
+
if (waited.permanentFailures.size === 0) {
|
|
1563
2410
|
notes.push(
|
|
1564
2411
|
waited.degraded
|
|
1565
|
-
? 'could not reach the server
|
|
1566
|
-
: 'no answer in time;
|
|
2412
|
+
? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
|
|
2413
|
+
: 'no answer in time; the question expired with its continuation owner',
|
|
1567
2414
|
)
|
|
1568
2415
|
}
|
|
1569
2416
|
return { notes }
|
|
1570
2417
|
}
|
|
1571
2418
|
|
|
2419
|
+
const polledAnswered = waitingOn.filter((entry) => waited.byRequest.has(entry.request_id!))
|
|
2420
|
+
const finalizedAnswered = await finalizePendings(ctx, polledAnswered)
|
|
1572
2421
|
const answered: AnsweredPending[] = []
|
|
1573
|
-
for (const
|
|
2422
|
+
for (const finalized of finalizedAnswered) {
|
|
2423
|
+
const entry = finalized.pending
|
|
1574
2424
|
const replies = waited.byRequest.get(entry.request_id!)
|
|
1575
2425
|
if (replies === undefined) continue
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
notes,
|
|
2426
|
+
// The fenced set wins because it includes corrections that committed
|
|
2427
|
+
// while close was waiting for earlier writers. If finalization itself was
|
|
2428
|
+
// unreachable, never discard the durable reply already observed.
|
|
2429
|
+
const finalReplies = finalized.response?.replies ?? []
|
|
2430
|
+
const authoritative = finalReplies.length > 0 ? finalReplies : replies
|
|
2431
|
+
answered.push({ pending: entry, reply: authoritative.at(-1)!, replies: authoritative })
|
|
2432
|
+
if (finalized.response === null) {
|
|
2433
|
+
notes.push(
|
|
2434
|
+
`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
|
|
2435
|
+
)
|
|
2436
|
+
}
|
|
1588
2437
|
}
|
|
2438
|
+
const accepted = stageAcceptedAnswers(
|
|
2439
|
+
ctx,
|
|
2440
|
+
sessionId,
|
|
2441
|
+
answered,
|
|
2442
|
+
waitingOn.length - answered.length,
|
|
2443
|
+
)
|
|
2444
|
+
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
2445
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
1589
2446
|
}
|
|
1590
2447
|
|
|
1591
2448
|
// ---------------------------------------------------------------------------
|
|
@@ -1607,7 +2464,7 @@ export function handleSessionEnd(
|
|
|
1607
2464
|
): HookOutcome {
|
|
1608
2465
|
const notes: string[] = []
|
|
1609
2466
|
const sessionId = envelope.session_id
|
|
1610
|
-
if (!sessionId) return { notes }
|
|
2467
|
+
if (!sessionId) return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } }
|
|
1611
2468
|
if (envelope.cwd !== undefined) clearMatchingProjectSession(envelope.cwd, env, sessionId)
|
|
1612
2469
|
|
|
1613
2470
|
const state = readSessionState(sessionId, env)
|
|
@@ -1620,7 +2477,10 @@ export function handleSessionEnd(
|
|
|
1620
2477
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
1621
2478
|
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1622
2479
|
// state into an unretirable question during SessionEnd cleanup.
|
|
1623
|
-
return {
|
|
2480
|
+
return {
|
|
2481
|
+
notes,
|
|
2482
|
+
log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
|
|
2483
|
+
}
|
|
1624
2484
|
}
|
|
1625
2485
|
}
|
|
1626
2486
|
if (orphans.length > 0) {
|
|
@@ -1630,8 +2490,24 @@ export function handleSessionEnd(
|
|
|
1630
2490
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
1631
2491
|
)
|
|
1632
2492
|
}
|
|
2493
|
+
if (state.accepted !== undefined) {
|
|
2494
|
+
const preserved: SessionState = { accepted: state.accepted }
|
|
2495
|
+
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
2496
|
+
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
2497
|
+
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
2498
|
+
writeSessionState(sessionId, env, preserved)
|
|
2499
|
+
notes.push('preserved an accepted device answer for this exact session to resume')
|
|
2500
|
+
return {
|
|
2501
|
+
notes,
|
|
2502
|
+
log: {
|
|
2503
|
+
outcome: 'answer-preserved',
|
|
2504
|
+
queued_retirements: orphans.length,
|
|
2505
|
+
accepted_answers: state.accepted.answers.length,
|
|
2506
|
+
},
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
1633
2509
|
clearSessionState(sessionId, env)
|
|
1634
|
-
return { notes }
|
|
2510
|
+
return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } }
|
|
1635
2511
|
}
|
|
1636
2512
|
|
|
1637
2513
|
// ---------------------------------------------------------------------------
|
|
@@ -1676,6 +2552,7 @@ export function registerQuestion(
|
|
|
1676
2552
|
pending: [
|
|
1677
2553
|
...pending,
|
|
1678
2554
|
{
|
|
2555
|
+
question_id: `q_${randomBytes(12).toString('base64url')}`,
|
|
1679
2556
|
asked_at: now,
|
|
1680
2557
|
...question,
|
|
1681
2558
|
question: question.question.slice(0, MAX_STORED_QUESTION_CHARS),
|