@raidiant/notifai 0.4.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 +3 -4
- package/package.json +1 -1
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +375 -39
- package/src/commands.ts +410 -104
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +11 -40
- package/src/config.test.ts +21 -16
- package/src/config.ts +15 -69
- package/src/harnesses.test.ts +7 -21
- package/src/harnesses.ts +17 -19
- package/src/hooks.test.ts +780 -290
- package/src/hooks.ts +466 -339
- package/src/install-hooks.test.ts +44 -3
- package/src/install-hooks.ts +43 -28
- package/src/interactive.ts +2 -7
- package/src/logging.test.ts +3 -3
- package/src/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
package/src/hooks.ts
CHANGED
|
@@ -36,7 +36,12 @@ import { buildDraft } from './send.js'
|
|
|
36
36
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
37
37
|
import { withFileLock } from './file-lock.js'
|
|
38
38
|
import type { Logger } from './logging.js'
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
HARNESS_CAPABILITIES,
|
|
41
|
+
HARNESSES,
|
|
42
|
+
type DeliveryRoute,
|
|
43
|
+
type Harness,
|
|
44
|
+
} from './harnesses.js'
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* Harness hook handlers.
|
|
@@ -46,10 +51,13 @@ import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING, type Harness } from './ha
|
|
|
46
51
|
* and Codex can continue directly from a turn-end answer. Harnesses without a
|
|
47
52
|
* proven exact-session continuation fail closed at question admission.
|
|
48
53
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
53
61
|
*/
|
|
54
62
|
|
|
55
63
|
/** Fields we read from harness hook JSON. Everything else is passed through. */
|
|
@@ -94,16 +102,20 @@ export interface SessionState {
|
|
|
94
102
|
* costs nothing and a missed one costs a stale notification for ever.
|
|
95
103
|
*/
|
|
96
104
|
retiring?: RetiringQuestion[]
|
|
97
|
-
/**
|
|
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
|
+
*/
|
|
98
110
|
continuation?: {
|
|
99
111
|
answered_at: number
|
|
100
112
|
count: number
|
|
101
113
|
}
|
|
102
114
|
/**
|
|
103
|
-
* A phone answer durably captured but not yet acknowledged
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* the
|
|
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.
|
|
107
119
|
*/
|
|
108
120
|
accepted?: AcceptedAnswerDelivery
|
|
109
121
|
}
|
|
@@ -112,7 +124,20 @@ interface AcceptedAnswerDelivery {
|
|
|
112
124
|
answers: AnsweredPending[]
|
|
113
125
|
remaining: number
|
|
114
126
|
recorded_at: number
|
|
115
|
-
|
|
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
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -203,69 +228,50 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
|
203
228
|
return { ids, display: ids.join(', ') }
|
|
204
229
|
}
|
|
205
230
|
|
|
206
|
-
/** How often the grace window rechecks whether the user has come back. */
|
|
207
|
-
const GRACE_POLL_MS = 5_000
|
|
208
|
-
|
|
209
231
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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.
|
|
214
239
|
*/
|
|
215
|
-
const
|
|
216
|
-
const MIN_REPLY_WAIT_SECONDS = HOOK_TIMING.minimumReplySeconds
|
|
240
|
+
export const WAITER_CEILING_SECONDS = 480
|
|
217
241
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
| '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
|
|
225
248
|
|
|
226
249
|
/**
|
|
227
250
|
* The terminal-first wait: the question sits in the terminal for
|
|
228
251
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
229
252
|
* devices.
|
|
230
253
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* That safety depends entirely on the idle signal, so with no idle source this
|
|
238
|
-
* refuses to wait at all rather than holding a terminal it cannot monitor.
|
|
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.
|
|
239
259
|
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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.
|
|
244
264
|
*/
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// outside a plausible range restarts the window from now.
|
|
256
|
-
const elapsed = ctx.now() - askedAt
|
|
257
|
-
const start = elapsed >= 0 && elapsed <= MAX_PLAUSIBLE_SILENCE_MS ? askedAt : ctx.now()
|
|
258
|
-
const deadline = start + graceSeconds * 1000
|
|
259
|
-
|
|
260
|
-
for (;;) {
|
|
261
|
-
if (ctx.config.require_idle.value) {
|
|
262
|
-
const idle = ctx.idleSeconds()
|
|
263
|
-
if (idle === null) return 'no-signal'
|
|
264
|
-
if (idle < threshold) return 'user-returned'
|
|
265
|
-
}
|
|
266
|
-
if (ctx.now() >= deadline) return 'absent'
|
|
267
|
-
await ctx.sleep(Math.min(GRACE_POLL_MS, Math.max(0, deadline - ctx.now())))
|
|
268
|
-
}
|
|
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)
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
export interface HookOutcome {
|
|
@@ -277,6 +283,59 @@ export interface HookOutcome {
|
|
|
277
283
|
log?: Record<string, unknown>
|
|
278
284
|
}
|
|
279
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
|
|
337
|
+
}
|
|
338
|
+
|
|
280
339
|
function sessionStatePath(sessionId: string, env: NodeJS.ProcessEnv): string {
|
|
281
340
|
return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
|
|
282
341
|
}
|
|
@@ -397,7 +456,7 @@ export function pruneAbandonedSessions(
|
|
|
397
456
|
* known-dead PID is recoverable immediately. Only legacy/corrupt claims with
|
|
398
457
|
* no trustworthy PID fall back to an age limit.
|
|
399
458
|
*/
|
|
400
|
-
const CLAIM_TTL_MS =
|
|
459
|
+
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
|
|
401
460
|
const heldClaims = new Map<string, string>()
|
|
402
461
|
|
|
403
462
|
export function claimQuestionPush(
|
|
@@ -593,14 +652,26 @@ export function readProjectSession(
|
|
|
593
652
|
return readProjectSessionPointer(cwd, env, now, maxAgeMs)?.sessionId ?? null
|
|
594
653
|
}
|
|
595
654
|
|
|
596
|
-
|
|
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(
|
|
597
668
|
cwd: string,
|
|
598
669
|
env: NodeJS.ProcessEnv,
|
|
599
670
|
now: number,
|
|
600
671
|
maxAgeMs = 24 * 3600 * 1000,
|
|
601
|
-
):
|
|
672
|
+
): LiveProjectSessionPointer[] {
|
|
602
673
|
const file = projectSessionPointerPath(cwd, env)
|
|
603
|
-
|
|
674
|
+
return readStoredProjectSessionPointers(file)
|
|
604
675
|
.filter((entry) => now - entry.updatedAt <= maxAgeMs)
|
|
605
676
|
.filter((entry) => {
|
|
606
677
|
try {
|
|
@@ -616,7 +687,20 @@ export function readProjectSessionPointer(
|
|
|
616
687
|
}
|
|
617
688
|
})
|
|
618
689
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
619
|
-
|
|
690
|
+
.map((entry) => ({
|
|
691
|
+
sessionId: entry.sessionId,
|
|
692
|
+
harness: entry.harness,
|
|
693
|
+
updatedAt: entry.updatedAt,
|
|
694
|
+
}))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function readProjectSessionPointer(
|
|
698
|
+
cwd: string,
|
|
699
|
+
env: NodeJS.ProcessEnv,
|
|
700
|
+
now: number,
|
|
701
|
+
maxAgeMs = 24 * 3600 * 1000,
|
|
702
|
+
): ProjectSessionPointer | null {
|
|
703
|
+
const latest = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs)[0]
|
|
620
704
|
return latest === undefined ? null : { sessionId: latest.sessionId, harness: latest.harness }
|
|
621
705
|
}
|
|
622
706
|
|
|
@@ -628,26 +712,10 @@ export function readMatchingProjectSessionPointer(
|
|
|
628
712
|
harness: HookHarness,
|
|
629
713
|
maxAgeMs = 24 * 3600 * 1000,
|
|
630
714
|
): ProjectSessionPointer | null {
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
(entry) =>
|
|
634
|
-
entry.sessionId === sessionId &&
|
|
635
|
-
entry.harness === harness &&
|
|
636
|
-
now - entry.updatedAt <= maxAgeMs,
|
|
715
|
+
const match = readLiveProjectSessionPointers(cwd, env, now, maxAgeMs).find(
|
|
716
|
+
(entry) => entry.sessionId === sessionId && entry.harness === harness,
|
|
637
717
|
)
|
|
638
|
-
|
|
639
|
-
try {
|
|
640
|
-
// A pointer is routing evidence only while its session state still exists
|
|
641
|
-
// and parses. SessionEnd and explicit cleanup therefore invalidate it even
|
|
642
|
-
// when a crash prevented the pointer file itself from being removed.
|
|
643
|
-
const sessionFile = sessionStatePath(match.sessionId, env)
|
|
644
|
-
if (!existsSync(sessionFile)) return null
|
|
645
|
-
const state: unknown = JSON.parse(readFileSync(sessionFile, 'utf8'))
|
|
646
|
-
if (typeof state !== 'object' || state === null || Array.isArray(state)) return null
|
|
647
|
-
return { sessionId: match.sessionId, harness: match.harness }
|
|
648
|
-
} catch {
|
|
649
|
-
return null
|
|
650
|
-
}
|
|
718
|
+
return match === undefined ? null : { sessionId: match.sessionId, harness: match.harness }
|
|
651
719
|
}
|
|
652
720
|
|
|
653
721
|
function clearMatchingProjectSession(
|
|
@@ -677,66 +745,11 @@ function clearMatchingProjectSession(
|
|
|
677
745
|
})
|
|
678
746
|
}
|
|
679
747
|
|
|
680
|
-
/**
|
|
681
|
-
* Absent means the user is not at this machine's keyboard.
|
|
682
|
-
*
|
|
683
|
-
* OS idle time answers that directly, so where it exists it decides alone.
|
|
684
|
-
* Silence since the user's last prompt is only ever a proxy for it, and a poor
|
|
685
|
-
* one — it is wrong in both directions:
|
|
686
|
-
*
|
|
687
|
-
* - Too long: it counts the agent's own turn, so a user watching a build was
|
|
688
|
-
* read as absent and had the question pushed at them.
|
|
689
|
-
* - Too short: a session that has just been spawned always has a fresh
|
|
690
|
-
* prompt, so its FIRST question could never escalate however long its
|
|
691
|
-
* owner had been gone. That is the "kick off some agents and
|
|
692
|
-
* walk away" case this feature mainly exists for, and requiring both
|
|
693
|
-
* signals to agree is what broke it.
|
|
694
|
-
*
|
|
695
|
-
* The proxy therefore survives only as the fallback for machines with no idle
|
|
696
|
-
* source. There, never having seen a prompt is not evidence of absence — it is
|
|
697
|
-
* a missing `UserPromptSubmit` hook — so it resolves to "present".
|
|
698
|
-
*
|
|
699
|
-
* Answering from a companion device does not make the user present; only touching this
|
|
700
|
-
* machine does. Answering on a device is evidence of being away from it.
|
|
701
|
-
*
|
|
702
|
-
* All of which only matters if presence is being consulted at all. With
|
|
703
|
-
* `require_idle` off the user has said their whereabouts are not a
|
|
704
|
-
* precondition, so this stops guessing and answers yes.
|
|
705
|
-
*/
|
|
706
|
-
export function isUserAway(
|
|
707
|
-
state: SessionState,
|
|
708
|
-
config: CliConfig,
|
|
709
|
-
now: number,
|
|
710
|
-
idleSeconds: number | null,
|
|
711
|
-
): boolean {
|
|
712
|
-
if (!config.require_idle.value) return true
|
|
713
|
-
const threshold = config.away_after_seconds.value
|
|
714
|
-
if (idleSeconds !== null) return idleSeconds >= threshold
|
|
715
|
-
if (state.last_prompt_at === undefined) return false
|
|
716
|
-
const silence = now - state.last_prompt_at
|
|
717
|
-
// Both signs of a clock jump produce a wrong answer here, and the fallback
|
|
718
|
-
// path has no monotonic reference to check against: a forward jump (NTP
|
|
719
|
-
// correction, VM resume) hijacks a terminal whose user is sitting right
|
|
720
|
-
// there, and a backward one suppresses escalation for someone genuinely
|
|
721
|
-
// gone. Neither delta is evidence of anything, so it resolves the way every
|
|
722
|
-
// other absence of evidence does — present.
|
|
723
|
-
if (silence < 0 || silence > MAX_PLAUSIBLE_SILENCE_MS) return false
|
|
724
|
-
return silence >= threshold * 1000
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Beyond this, silence says more about the clock or an abandoned session file
|
|
729
|
-
* than about the user. Matches the project pointer's staleness horizon.
|
|
730
|
-
*/
|
|
731
|
-
const MAX_PLAUSIBLE_SILENCE_MS = 24 * 3600 * 1000
|
|
732
|
-
|
|
733
748
|
export interface HookContext {
|
|
734
749
|
client: ApiClient
|
|
735
750
|
config: CliConfig
|
|
736
751
|
env: NodeJS.ProcessEnv
|
|
737
752
|
now: () => number
|
|
738
|
-
/** Seconds since the last keyboard/mouse event, or null if unknowable. */
|
|
739
|
-
idleSeconds: () => number | null
|
|
740
753
|
/** Injected so tests advance a virtual clock instead of sleeping. */
|
|
741
754
|
sleep: (milliseconds: number) => Promise<void>
|
|
742
755
|
/** Bounded wait for the first reply; injected so tests do not sleep. */
|
|
@@ -770,10 +783,9 @@ export type GateReason =
|
|
|
770
783
|
| 'already-asked'
|
|
771
784
|
| 'continuation-repeat'
|
|
772
785
|
| 'continuation-limit'
|
|
786
|
+
| 'delivery-limit'
|
|
773
787
|
| 'notifications-off'
|
|
774
|
-
| 'user-present'
|
|
775
788
|
| 'claimed-elsewhere'
|
|
776
|
-
| 'user-returned'
|
|
777
789
|
| 'no-devices'
|
|
778
790
|
| 'proceeding'
|
|
779
791
|
|
|
@@ -783,13 +795,16 @@ function gate(
|
|
|
783
795
|
reason: GateReason,
|
|
784
796
|
data: Record<string, unknown> = {},
|
|
785
797
|
): void {
|
|
786
|
-
ctx.log?.info('hook.gate', { verdict, reason, ...data })
|
|
798
|
+
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data })
|
|
787
799
|
}
|
|
788
800
|
|
|
789
801
|
export type HookHarness = Harness
|
|
790
802
|
|
|
791
|
-
/**
|
|
792
|
-
|
|
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
|
|
793
808
|
|
|
794
809
|
/**
|
|
795
810
|
* Push a question and block for the answer.
|
|
@@ -875,20 +890,21 @@ async function submitQuestion(
|
|
|
875
890
|
}
|
|
876
891
|
|
|
877
892
|
/**
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
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.
|
|
882
900
|
*/
|
|
883
|
-
async function
|
|
901
|
+
async function waitForAnyReply(
|
|
884
902
|
ctx: HookContext,
|
|
885
903
|
requestIds: string[],
|
|
886
904
|
timeoutSeconds: number,
|
|
887
|
-
monitorPresence = true,
|
|
888
905
|
): Promise<{
|
|
889
906
|
byRequest: Map<string, ReplyView[]>
|
|
890
907
|
degraded: boolean
|
|
891
|
-
userReturned: boolean
|
|
892
908
|
permanentFailures: Map<string, string>
|
|
893
909
|
}> {
|
|
894
910
|
const deadline = ctx.now() + timeoutSeconds * 1000
|
|
@@ -897,22 +913,12 @@ async function waitForAnyReplyWhileAway(
|
|
|
897
913
|
const permanentFailures = new Map<string, string>()
|
|
898
914
|
|
|
899
915
|
for (;;) {
|
|
900
|
-
if (monitorPresence && ctx.config.require_idle.value) {
|
|
901
|
-
const idle = ctx.idleSeconds()
|
|
902
|
-
if (idle !== null && idle < ctx.config.away_after_seconds.value) {
|
|
903
|
-
return { byRequest: new Map(), degraded, userReturned: true, permanentFailures }
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
916
|
const remainingMs = Math.max(0, deadline - ctx.now())
|
|
908
917
|
if (!firstPoll && remainingMs === 0) {
|
|
909
|
-
return { byRequest: new Map(), degraded,
|
|
918
|
+
return { byRequest: new Map(), degraded, permanentFailures }
|
|
910
919
|
}
|
|
911
920
|
firstPoll = false
|
|
912
|
-
const pollSeconds = Math.min(
|
|
913
|
-
REPLY_PRESENCE_POLL_SECONDS,
|
|
914
|
-
Math.max(0, Math.ceil(remainingMs / 1000)),
|
|
915
|
-
)
|
|
921
|
+
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)))
|
|
916
922
|
const results = await Promise.all(
|
|
917
923
|
requestIds
|
|
918
924
|
.filter((requestId) => !permanentFailures.has(requestId))
|
|
@@ -941,7 +947,7 @@ async function waitForAnyReplyWhileAway(
|
|
|
941
947
|
if (result.replies.length > 0) byRequest.set(result.requestId, result.replies)
|
|
942
948
|
}
|
|
943
949
|
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
944
|
-
return { byRequest, degraded,
|
|
950
|
+
return { byRequest, degraded, permanentFailures }
|
|
945
951
|
}
|
|
946
952
|
}
|
|
947
953
|
}
|
|
@@ -1285,7 +1291,15 @@ export async function drainOrphanRetirements(
|
|
|
1285
1291
|
|
|
1286
1292
|
const LATE_STOP_POLL_SECONDS = 4
|
|
1287
1293
|
const LATE_PROMPT_POLL_SECONDS = 3
|
|
1288
|
-
|
|
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
|
|
1289
1303
|
|
|
1290
1304
|
interface PendingPoll {
|
|
1291
1305
|
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
@@ -1361,6 +1375,7 @@ function reportAnswer(
|
|
|
1361
1375
|
): void {
|
|
1362
1376
|
ctx.log?.info('hook.answer', {
|
|
1363
1377
|
answered: true,
|
|
1378
|
+
stage: 'queued',
|
|
1364
1379
|
...(late ? { late: true } : {}),
|
|
1365
1380
|
request_id: answered.pending.request_id,
|
|
1366
1381
|
device: answered.reply.device_name,
|
|
@@ -1429,18 +1444,28 @@ function pendingHasChoices(pending: PendingQuestion): boolean {
|
|
|
1429
1444
|
* it. Free-text answers can arrive in parts, and every part reaches the
|
|
1430
1445
|
* agent in the order it was written, so it can tell expansion from
|
|
1431
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.
|
|
1432
1451
|
*/
|
|
1433
|
-
function answerContext(
|
|
1452
|
+
function answerContext(answered: AnsweredPending): string {
|
|
1453
|
+
const { pending, replies } = answered
|
|
1434
1454
|
const latest = replies.at(-1)
|
|
1435
1455
|
if (latest === undefined) return 'Notifai — no answer was recorded.'
|
|
1436
|
-
|
|
1437
|
-
|
|
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)}.`
|
|
1438
1464
|
}
|
|
1439
|
-
const parts = replies.map((reply) =>
|
|
1465
|
+
const parts = replies.map((reply) => JSON.stringify(reply.text)).join(', then ')
|
|
1440
1466
|
return (
|
|
1441
|
-
`Notifai — the user answered
|
|
1442
|
-
`in the order written: ${parts}. Later parts extend or correct earlier ones
|
|
1443
|
-
'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.`
|
|
1444
1469
|
)
|
|
1445
1470
|
}
|
|
1446
1471
|
|
|
@@ -1456,18 +1481,19 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
|
|
|
1456
1481
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
1457
1482
|
: ''
|
|
1458
1483
|
if (answered.length === 1) {
|
|
1459
|
-
|
|
1460
|
-
return answerContext(only.replies, pendingHasChoices(only.pending)) + tail
|
|
1484
|
+
return answerContext(answered[0]!) + tail
|
|
1461
1485
|
}
|
|
1462
1486
|
const lines = answered.map(({ pending, replies }) => {
|
|
1463
1487
|
const latest = replies.at(-1)!
|
|
1464
1488
|
const answer =
|
|
1465
1489
|
replies.length === 1 || pendingHasChoices(pending)
|
|
1466
|
-
?
|
|
1467
|
-
: `${replies.map((reply) =>
|
|
1468
|
-
|
|
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}`
|
|
1469
1495
|
})
|
|
1470
|
-
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}`
|
|
1471
1497
|
}
|
|
1472
1498
|
|
|
1473
1499
|
function stopAnswerOutput(context: string): string {
|
|
@@ -1488,26 +1514,46 @@ function stageAcceptedAnswers(
|
|
|
1488
1514
|
sessionId: string,
|
|
1489
1515
|
answered: AnsweredPending[],
|
|
1490
1516
|
remaining: number,
|
|
1491
|
-
):
|
|
1517
|
+
): AcceptedAnswerDelivery {
|
|
1518
|
+
const accepted: AcceptedAnswerDelivery = {
|
|
1519
|
+
answers: answered,
|
|
1520
|
+
remaining,
|
|
1521
|
+
recorded_at: ctx.now(),
|
|
1522
|
+
}
|
|
1492
1523
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1493
1524
|
const pendingRemaining = pendingList(current).filter(
|
|
1494
1525
|
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1495
1526
|
)
|
|
1496
1527
|
const next: SessionState = {
|
|
1497
1528
|
...current,
|
|
1498
|
-
accepted
|
|
1499
|
-
answers: answered,
|
|
1500
|
-
remaining,
|
|
1501
|
-
recorded_at: ctx.now(),
|
|
1502
|
-
},
|
|
1529
|
+
accepted,
|
|
1503
1530
|
}
|
|
1504
1531
|
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
1505
1532
|
else delete next.pending
|
|
1506
1533
|
return next
|
|
1507
1534
|
})
|
|
1535
|
+
return accepted
|
|
1508
1536
|
}
|
|
1509
1537
|
|
|
1510
|
-
/**
|
|
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
|
+
*/
|
|
1511
1557
|
function settleAcceptedAnswers(
|
|
1512
1558
|
ctx: HookContext,
|
|
1513
1559
|
sessionId: string,
|
|
@@ -1527,7 +1573,7 @@ function settleAcceptedAnswers(
|
|
|
1527
1573
|
delete next.accepted
|
|
1528
1574
|
next.continuation = {
|
|
1529
1575
|
answered_at: accepted.recorded_at,
|
|
1530
|
-
count:
|
|
1576
|
+
count: (current.continuation?.count ?? 0) + 1,
|
|
1531
1577
|
}
|
|
1532
1578
|
return next
|
|
1533
1579
|
})
|
|
@@ -1590,7 +1636,11 @@ export async function handleUserPromptSubmit(
|
|
|
1590
1636
|
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
1591
1637
|
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness)
|
|
1592
1638
|
}
|
|
1593
|
-
notes.push(
|
|
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
|
+
)
|
|
1594
1644
|
return { notes }
|
|
1595
1645
|
}
|
|
1596
1646
|
const live = pendingList(state).filter((entry) => entry.request_id !== undefined)
|
|
@@ -1653,7 +1703,14 @@ export async function handleUserPromptSubmit(
|
|
|
1653
1703
|
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1654
1704
|
...(retiring.length > 0 ? { retiring } : {}),
|
|
1655
1705
|
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1656
|
-
|
|
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 } }),
|
|
1657
1714
|
}
|
|
1658
1715
|
})
|
|
1659
1716
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
@@ -1691,15 +1748,34 @@ export async function handleUserPromptSubmit(
|
|
|
1691
1748
|
export async function handleStop(
|
|
1692
1749
|
ctx: HookContext,
|
|
1693
1750
|
envelope: HookEnvelope,
|
|
1694
|
-
processDeadlineAt = ctx.now() +
|
|
1751
|
+
processDeadlineAt = ctx.now() + WAITER_CEILING_SECONDS * 1000,
|
|
1752
|
+
route: EscalationDeliveryRoute = hookContinuationRoute(),
|
|
1695
1753
|
): Promise<HookOutcome> {
|
|
1696
|
-
const notes: string[] = []
|
|
1697
|
-
const hardDeadlineAt = processDeadlineAt
|
|
1698
1754
|
const sessionId = envelope.session_id
|
|
1699
1755
|
if (!sessionId) {
|
|
1700
|
-
gate(ctx, 'held', 'no-session')
|
|
1701
|
-
return { notes }
|
|
1756
|
+
gate(ctx, 'held', 'no-session', { stage: 'queued' })
|
|
1757
|
+
return { notes: [] }
|
|
1702
1758
|
}
|
|
1759
|
+
return runEscalationWaiter(ctx, {
|
|
1760
|
+
sessionId,
|
|
1761
|
+
envelope,
|
|
1762
|
+
route,
|
|
1763
|
+
processDeadlineAt,
|
|
1764
|
+
})
|
|
1765
|
+
}
|
|
1766
|
+
|
|
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
|
|
1703
1779
|
|
|
1704
1780
|
// This event marker is diagnostic evidence in its own right. Record it
|
|
1705
1781
|
// before every early return so doctor can distinguish a working Stop route
|
|
@@ -1709,47 +1785,39 @@ export async function handleStop(
|
|
|
1709
1785
|
last_stop_at: ctx.now(),
|
|
1710
1786
|
}))
|
|
1711
1787
|
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
// replay/ack, late-answer collection, cleanup, and any new escalation.
|
|
1717
|
-
//
|
|
1718
|
-
// Real clock, deliberately, not `ctx.now` — the claim answers "is another
|
|
1719
|
-
// process alive right now", which the injectable clock cannot speak to. It
|
|
1720
|
-
// is also the only clock the *other* process shares.
|
|
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.
|
|
1721
1792
|
if (!claimQuestionPush(sessionId, ctx.env)) {
|
|
1722
|
-
gate(ctx, 'held', 'claimed-elsewhere')
|
|
1793
|
+
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' })
|
|
1723
1794
|
notes.push('another hook is already handling this question')
|
|
1724
1795
|
return { notes }
|
|
1725
1796
|
}
|
|
1797
|
+
let settledAnswerThisPass = false
|
|
1726
1798
|
try {
|
|
1727
1799
|
let state = readSessionState(sessionId, ctx.env)
|
|
1728
1800
|
if (state.accepted !== undefined) {
|
|
1729
1801
|
const accepted = state.accepted
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
|
|
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
|
|
1735
1815
|
if (!deliveryProven) {
|
|
1736
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1737
|
-
if (current.accepted === undefined) return current
|
|
1738
|
-
return {
|
|
1739
|
-
...current,
|
|
1740
|
-
accepted: {
|
|
1741
|
-
...current.accepted,
|
|
1742
|
-
continuation_count: (current.continuation?.count ?? 0) + 1,
|
|
1743
|
-
},
|
|
1744
|
-
}
|
|
1745
|
-
})
|
|
1746
1816
|
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
1747
|
-
return
|
|
1748
|
-
stdout: stopAnswerOutput(answersContext(accepted.answers, accepted.remaining)),
|
|
1749
|
-
notes,
|
|
1750
|
-
}
|
|
1817
|
+
return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes)
|
|
1751
1818
|
}
|
|
1752
1819
|
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1820
|
+
settledAnswerThisPass = true
|
|
1753
1821
|
state = readSessionState(sessionId, ctx.env)
|
|
1754
1822
|
}
|
|
1755
1823
|
|
|
@@ -1762,7 +1830,7 @@ export async function handleStop(
|
|
|
1762
1830
|
if (swept.length > 0) {
|
|
1763
1831
|
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`)
|
|
1764
1832
|
}
|
|
1765
|
-
gate(ctx, 'held', 'no-question')
|
|
1833
|
+
gate(ctx, 'held', 'no-question', { stage: 'queued' })
|
|
1766
1834
|
return { notes }
|
|
1767
1835
|
}
|
|
1768
1836
|
|
|
@@ -1774,12 +1842,120 @@ export async function handleStop(
|
|
|
1774
1842
|
pending,
|
|
1775
1843
|
notes,
|
|
1776
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,
|
|
1777
1850
|
)
|
|
1778
1851
|
} finally {
|
|
1779
1852
|
releaseQuestionPush(sessionId, ctx.env)
|
|
1780
1853
|
}
|
|
1781
1854
|
}
|
|
1782
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
|
+
|
|
1783
1959
|
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1784
1960
|
async function handleClaimedStop(
|
|
1785
1961
|
ctx: HookContext,
|
|
@@ -1789,6 +1965,8 @@ async function handleClaimedStop(
|
|
|
1789
1965
|
pending: PendingQuestion[],
|
|
1790
1966
|
notes: string[],
|
|
1791
1967
|
hardDeadlineAt: number,
|
|
1968
|
+
route: EscalationDeliveryRoute,
|
|
1969
|
+
continuingFromAnswer: boolean,
|
|
1792
1970
|
): Promise<HookOutcome> {
|
|
1793
1971
|
const live = pending.filter((entry) => entry.request_id !== undefined)
|
|
1794
1972
|
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
@@ -1837,25 +2015,16 @@ async function handleClaimedStop(
|
|
|
1837
2015
|
}
|
|
1838
2016
|
return { ...entry, replies, reply: replies.at(-1)! }
|
|
1839
2017
|
})
|
|
1840
|
-
stageAcceptedAnswers(
|
|
2018
|
+
const accepted = stageAcceptedAnswers(
|
|
1841
2019
|
ctx,
|
|
1842
2020
|
sessionId,
|
|
1843
2021
|
authoritative,
|
|
1844
2022
|
pending.length - authoritative.length - permanentlyRejected.length,
|
|
1845
2023
|
)
|
|
1846
|
-
gate(ctx, 'proceeding', 'answered', { answers: authoritative.length })
|
|
1847
2024
|
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
1848
2025
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1849
2026
|
// with answers right now, and may not even need the rest afterwards.
|
|
1850
|
-
return
|
|
1851
|
-
stdout: stopAnswerOutput(
|
|
1852
|
-
answersContext(
|
|
1853
|
-
authoritative,
|
|
1854
|
-
pending.length - authoritative.length - permanentlyRejected.length,
|
|
1855
|
-
),
|
|
1856
|
-
),
|
|
1857
|
-
notes,
|
|
1858
|
-
}
|
|
2027
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
1859
2028
|
}
|
|
1860
2029
|
const recoverableLive = live.filter(
|
|
1861
2030
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
@@ -1875,6 +2044,7 @@ async function handleClaimedStop(
|
|
|
1875
2044
|
recoverableLive,
|
|
1876
2045
|
notes,
|
|
1877
2046
|
hardDeadlineAt,
|
|
2047
|
+
route,
|
|
1878
2048
|
)
|
|
1879
2049
|
}
|
|
1880
2050
|
// Questions registered after the earlier push still owe the user their
|
|
@@ -1896,16 +2066,26 @@ async function handleClaimedStop(
|
|
|
1896
2066
|
notes.push('already continuing from an answer; not asking again this turn')
|
|
1897
2067
|
return { notes }
|
|
1898
2068
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
2069
|
+
}
|
|
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
|
+
)
|
|
2088
|
+
return { notes }
|
|
1909
2089
|
}
|
|
1910
2090
|
// Silent to the user by design — they switched routing off, so saying so on
|
|
1911
2091
|
// every turn would be nagging about their own setting. That silence is also
|
|
@@ -1915,21 +2095,9 @@ async function handleClaimedStop(
|
|
|
1915
2095
|
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source })
|
|
1916
2096
|
return { notes }
|
|
1917
2097
|
}
|
|
1918
|
-
const idle = ctx.idleSeconds()
|
|
1919
|
-
if (!isUserAway(state, ctx.config, ctx.now(), idle)) {
|
|
1920
|
-
gate(ctx, 'held', 'user-present', {
|
|
1921
|
-
idle_seconds: idle,
|
|
1922
|
-
away_after_seconds: ctx.config.away_after_seconds.value,
|
|
1923
|
-
require_idle: ctx.config.require_idle.value,
|
|
1924
|
-
last_prompt_at: state.last_prompt_at ?? null,
|
|
1925
|
-
})
|
|
1926
|
-
notes.push('you are at the keyboard; leaving the question in the terminal')
|
|
1927
|
-
return { notes }
|
|
1928
|
-
}
|
|
1929
2098
|
gate(ctx, 'proceeding', 'proceeding', {
|
|
1930
2099
|
unasked: unasked.length,
|
|
1931
2100
|
already_live: liveToEscalate.length,
|
|
1932
|
-
idle_seconds: idle,
|
|
1933
2101
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1934
2102
|
})
|
|
1935
2103
|
return await escalate(
|
|
@@ -1940,6 +2108,7 @@ async function handleClaimedStop(
|
|
|
1940
2108
|
liveToEscalate,
|
|
1941
2109
|
notes,
|
|
1942
2110
|
hardDeadlineAt,
|
|
2111
|
+
route,
|
|
1943
2112
|
)
|
|
1944
2113
|
}
|
|
1945
2114
|
|
|
@@ -1952,56 +2121,46 @@ async function escalate(
|
|
|
1952
2121
|
alreadyLive: PendingQuestion[],
|
|
1953
2122
|
notes: string[],
|
|
1954
2123
|
hardDeadlineAt: number,
|
|
2124
|
+
route: EscalationDeliveryRoute,
|
|
1955
2125
|
): Promise<HookOutcome> {
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1958
|
-
//
|
|
1959
|
-
const
|
|
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()
|
|
1960
2130
|
const existingDeadlines = alreadyLive.flatMap((entry) =>
|
|
1961
|
-
entry.reply_deadline_at === undefined || entry.reply_deadline_at <=
|
|
2131
|
+
entry.reply_deadline_at === undefined || entry.reply_deadline_at <= startedAt
|
|
1962
2132
|
? []
|
|
1963
2133
|
: [entry.reply_deadline_at],
|
|
1964
2134
|
)
|
|
1965
|
-
const
|
|
1966
|
-
hardDeadlineAt
|
|
2135
|
+
const ceilingAt = Math.min(
|
|
2136
|
+
hardDeadlineAt,
|
|
1967
2137
|
...(existingDeadlines.length === 0 ? [] : existingDeadlines),
|
|
1968
2138
|
)
|
|
1969
2139
|
if (unasked.length > 0 && alreadyLive.length === 0) {
|
|
1970
2140
|
const oldest = Math.min(...unasked.map((entry) => entry.asked_at ?? ctx.now()))
|
|
1971
|
-
|
|
1972
|
-
ctx.log?.debug('hook.gate', {
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
}
|
|
1978
|
-
if (grace === 'no-signal') {
|
|
1979
|
-
notes.push('no idle signal on this machine; asking now rather than holding the terminal')
|
|
1980
|
-
}
|
|
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
|
+
})
|
|
1981
2148
|
}
|
|
1982
2149
|
// Phase one: every registered question reaches the user's devices, each as
|
|
1983
2150
|
// its own notification — one ask never stands in for another.
|
|
1984
2151
|
const submitted: PendingQuestion[] = []
|
|
1985
2152
|
for (const entry of unasked) {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
) {
|
|
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) {
|
|
1991
2158
|
notes.push(
|
|
1992
|
-
'
|
|
2159
|
+
'too little of the waiter ceiling is left for a reply window the server would accept; leaving this question in the terminal',
|
|
1993
2160
|
)
|
|
1994
2161
|
continue
|
|
1995
2162
|
}
|
|
1996
|
-
const
|
|
1997
|
-
ctx.config.hook_reply_timeout_seconds.value,
|
|
1998
|
-
remainingSeconds - HOOK_TIMING.submissionReserveSeconds,
|
|
1999
|
-
)
|
|
2000
|
-
const ownerDeadlineAt = Math.min(
|
|
2001
|
-
budgetDeadlineAt,
|
|
2002
|
-
ctx.now() +
|
|
2003
|
-
(replyWindowSeconds + HOOK_TIMING.submissionReserveSeconds) * 1000,
|
|
2004
|
-
)
|
|
2163
|
+
const ownerDeadlineAt = ceilingAt
|
|
2005
2164
|
const questions = pendingQuestions(entry)
|
|
2006
2165
|
let intent = entry.submission
|
|
2007
2166
|
if (intent === undefined) {
|
|
@@ -2044,16 +2203,7 @@ async function escalate(
|
|
|
2044
2203
|
// The frozen wire identity survives a crashed owner, but its local owner
|
|
2045
2204
|
// lease does not. Re-arm only the local deadline; request id, key,
|
|
2046
2205
|
// targets, and draft remain byte-identical for idempotent replay.
|
|
2047
|
-
|
|
2048
|
-
intent.draft.reply?.expires_in_seconds ?? replyWindowSeconds
|
|
2049
|
-
intent = {
|
|
2050
|
-
...intent,
|
|
2051
|
-
owner_deadline_at: Math.min(
|
|
2052
|
-
budgetDeadlineAt,
|
|
2053
|
-
ctx.now() +
|
|
2054
|
-
(frozenReplyWindow + HOOK_TIMING.submissionReserveSeconds) * 1000,
|
|
2055
|
-
),
|
|
2056
|
-
}
|
|
2206
|
+
intent = { ...intent, owner_deadline_at: ceilingAt }
|
|
2057
2207
|
const rearmed = intent
|
|
2058
2208
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2059
2209
|
const list = pendingList(current)
|
|
@@ -2065,16 +2215,13 @@ async function escalate(
|
|
|
2065
2215
|
})
|
|
2066
2216
|
}
|
|
2067
2217
|
if (ctx.now() >= intent.owner_deadline_at) {
|
|
2068
|
-
notes.push('the
|
|
2218
|
+
notes.push('the waiter ceiling ended before submission; preserving the frozen intent')
|
|
2069
2219
|
continue
|
|
2070
2220
|
}
|
|
2071
2221
|
const beforeSubmitSeconds = Math.floor((intent.owner_deadline_at - ctx.now()) / 1000)
|
|
2072
|
-
if (
|
|
2073
|
-
beforeSubmitSeconds <
|
|
2074
|
-
MIN_REPLY_WAIT_SECONDS + HOOK_TIMING.submissionReserveSeconds / 2
|
|
2075
|
-
) {
|
|
2222
|
+
if (beforeSubmitSeconds < MIN_REPLY_WINDOW_SECONDS) {
|
|
2076
2223
|
notes.push(
|
|
2077
|
-
'device resolution consumed the
|
|
2224
|
+
'device resolution consumed the reply window; preserving the frozen intent instead of shrinking it below what the server accepts',
|
|
2078
2225
|
)
|
|
2079
2226
|
continue
|
|
2080
2227
|
}
|
|
@@ -2195,12 +2342,9 @@ async function escalate(
|
|
|
2195
2342
|
0,
|
|
2196
2343
|
pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length,
|
|
2197
2344
|
)
|
|
2198
|
-
stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2345
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2199
2346
|
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2200
|
-
return
|
|
2201
|
-
stdout: stopAnswerOutput(answersContext(staleAnswers, remaining)),
|
|
2202
|
-
notes,
|
|
2203
|
-
}
|
|
2347
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2204
2348
|
}
|
|
2205
2349
|
const waitingOn = [
|
|
2206
2350
|
...alreadyLive.filter(
|
|
@@ -2210,17 +2354,16 @@ async function escalate(
|
|
|
2210
2354
|
]
|
|
2211
2355
|
if (waitingOn.length === 0) return { notes }
|
|
2212
2356
|
|
|
2213
|
-
// Phase two: one
|
|
2357
|
+
// Phase two: one wait across everything live, old and new alike.
|
|
2214
2358
|
const ownerDeadline = Math.min(
|
|
2215
|
-
|
|
2359
|
+
ceilingAt,
|
|
2216
2360
|
Math.max(...waitingOn.map((entry) => entry.reply_deadline_at!)),
|
|
2217
2361
|
)
|
|
2218
2362
|
const timeoutSeconds = Math.max(0, Math.ceil((ownerDeadline - ctx.now()) / 1000))
|
|
2219
|
-
const waited = await
|
|
2363
|
+
const waited = await waitForAnyReply(
|
|
2220
2364
|
ctx,
|
|
2221
2365
|
waitingOn.map((entry) => entry.request_id!),
|
|
2222
2366
|
timeoutSeconds,
|
|
2223
|
-
ctx.harness !== 'opencode',
|
|
2224
2367
|
)
|
|
2225
2368
|
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures)
|
|
2226
2369
|
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
@@ -2238,13 +2381,7 @@ async function escalate(
|
|
|
2238
2381
|
.map((entry) => entry.pending)
|
|
2239
2382
|
const unproven = finalized.filter((entry) => entry.response === null)
|
|
2240
2383
|
if (confirmedSilent.length > 0) {
|
|
2241
|
-
await retirePendings(
|
|
2242
|
-
ctx,
|
|
2243
|
-
envelope,
|
|
2244
|
-
sessionId,
|
|
2245
|
-
confirmedSilent,
|
|
2246
|
-
waited.userReturned ? 'answered_elsewhere' : 'expired',
|
|
2247
|
-
)
|
|
2384
|
+
await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired')
|
|
2248
2385
|
}
|
|
2249
2386
|
if (unproven.length > 0) {
|
|
2250
2387
|
notes.push(
|
|
@@ -2256,27 +2393,20 @@ async function escalate(
|
|
|
2256
2393
|
0,
|
|
2257
2394
|
pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length,
|
|
2258
2395
|
)
|
|
2259
|
-
stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2396
|
+
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2260
2397
|
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
2261
|
-
return
|
|
2262
|
-
stdout: stopAnswerOutput(answersContext(finalAnswers, remaining)),
|
|
2263
|
-
notes,
|
|
2264
|
-
}
|
|
2398
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2265
2399
|
}
|
|
2266
2400
|
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
2267
2401
|
ctx.log?.info('hook.answer', {
|
|
2268
2402
|
answered: false,
|
|
2403
|
+
stage: 'queued',
|
|
2269
2404
|
request_ids: requestIdSummary.ids,
|
|
2270
|
-
user_returned: waited.userReturned,
|
|
2271
2405
|
degraded: waited.degraded,
|
|
2272
2406
|
permanent_failures: waited.permanentFailures.size,
|
|
2273
2407
|
waited_seconds: timeoutSeconds,
|
|
2274
2408
|
})
|
|
2275
|
-
if (waited.
|
|
2276
|
-
notes.push(
|
|
2277
|
-
`you came back after the question${waitingOn.length === 1 ? ' was' : 's were'} sent; ${waitingOn.length === 1 ? 'it was' : 'they were'} retired before returning the terminal (${requestIdSummary.display})`,
|
|
2278
|
-
)
|
|
2279
|
-
} else if (waited.permanentFailures.size === 0) {
|
|
2409
|
+
if (waited.permanentFailures.size === 0) {
|
|
2280
2410
|
notes.push(
|
|
2281
2411
|
waited.degraded
|
|
2282
2412
|
? 'could not reach the server before the owner deadline; the question was retired so no answer can be lost later'
|
|
@@ -2305,17 +2435,14 @@ async function escalate(
|
|
|
2305
2435
|
)
|
|
2306
2436
|
}
|
|
2307
2437
|
}
|
|
2308
|
-
stageAcceptedAnswers(
|
|
2438
|
+
const accepted = stageAcceptedAnswers(
|
|
2309
2439
|
ctx,
|
|
2310
2440
|
sessionId,
|
|
2311
2441
|
answered,
|
|
2312
2442
|
waitingOn.length - answered.length,
|
|
2313
2443
|
)
|
|
2314
2444
|
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
2315
|
-
return
|
|
2316
|
-
stdout: stopAnswerOutput(answersContext(answered, waitingOn.length - answered.length)),
|
|
2317
|
-
notes,
|
|
2318
|
-
}
|
|
2445
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2319
2446
|
}
|
|
2320
2447
|
|
|
2321
2448
|
// ---------------------------------------------------------------------------
|