@raidiant/notifai 3.0.1 → 5.0.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/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/hooks.ts
CHANGED
|
@@ -11,12 +11,15 @@ import {
|
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
14
15
|
import type {
|
|
15
16
|
LifecycleEndState,
|
|
16
17
|
ListRepliesResponse,
|
|
18
|
+
MediaItemT,
|
|
17
19
|
NotificationDraftT,
|
|
18
20
|
QuestionT,
|
|
19
21
|
ReplyView,
|
|
22
|
+
SourceContextT,
|
|
20
23
|
SubmissionReceipt,
|
|
21
24
|
} from '@raidiant/notifai-protocol'
|
|
22
25
|
import {
|
|
@@ -125,11 +128,19 @@ export interface SessionState {
|
|
|
125
128
|
* the service confirms the agent-authored follow-up exists.
|
|
126
129
|
*/
|
|
127
130
|
acknowledgement_due?: AcknowledgementDue[]
|
|
131
|
+
/** Consecutive turns this session has been held for an acknowledgement. */
|
|
132
|
+
acknowledgement_blocks?: number
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
export interface AcknowledgementDue {
|
|
131
136
|
request_id: string
|
|
132
137
|
recorded_at: number
|
|
138
|
+
/**
|
|
139
|
+
* Whether this request's acknowledgement must carry text. Absent on state
|
|
140
|
+
* written before the obligation started recording it; treated as true, which
|
|
141
|
+
* is the default the service ships.
|
|
142
|
+
*/
|
|
143
|
+
text_required?: boolean
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
interface AcceptedAnswerDelivery {
|
|
@@ -150,6 +161,8 @@ interface AcceptedAnswerDelivery {
|
|
|
150
161
|
* passes through.
|
|
151
162
|
*/
|
|
152
163
|
delivery_attempts?: number
|
|
164
|
+
/** Turns this answer has been held without being handed to the agent. */
|
|
165
|
+
held_deliveries?: number
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
/** A delivered question awaiting its retirement push. */
|
|
@@ -160,6 +173,8 @@ export interface RetiringQuestion {
|
|
|
160
173
|
device_ids: string[]
|
|
161
174
|
/** Shown if the companion has no history entry to correlate against. */
|
|
162
175
|
question: string
|
|
176
|
+
project?: string
|
|
177
|
+
source?: SourceContextT
|
|
163
178
|
state: LifecycleEndState
|
|
164
179
|
}
|
|
165
180
|
|
|
@@ -175,19 +190,20 @@ export interface RetiringQuestion {
|
|
|
175
190
|
* next holds a client.
|
|
176
191
|
*/
|
|
177
192
|
export interface OrphanRetirement extends RetiringQuestion {
|
|
178
|
-
/** Label of the session that asked, so the retirement sync matches its badge. */
|
|
179
|
-
session?: string
|
|
180
193
|
/** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
|
|
181
194
|
enqueued_at: number
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
198
|
+
* A retirement is only worth syncing while the question it retires could still
|
|
199
|
+
* be answered, so this has to outlive the longest reply window a user can
|
|
200
|
+
* choose — it is derived from that bound rather than from a guess about how
|
|
201
|
+
* long questions stay open, which is exactly the assumption that broke when
|
|
202
|
+
* the window stopped being one hour. Past it the companion shows the question
|
|
203
|
+
* as dead on next open anyway. Also the backstop that keeps an unreachable
|
|
204
|
+
* server from growing the queue for ever.
|
|
189
205
|
*/
|
|
190
|
-
const ORPHAN_TTL_MS =
|
|
206
|
+
const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000
|
|
191
207
|
|
|
192
208
|
/** More orphans than this means something is looping; keep the newest. */
|
|
193
209
|
const ORPHAN_QUEUE_CAP = 50
|
|
@@ -208,8 +224,13 @@ export interface PendingQuestion {
|
|
|
208
224
|
* texts, choices, multi flags. What actually rides the push.
|
|
209
225
|
*/
|
|
210
226
|
questions?: QuestionT[]
|
|
211
|
-
/**
|
|
212
|
-
|
|
227
|
+
/** Canonical Markdown body composed when the question was registered. */
|
|
228
|
+
body?: string
|
|
229
|
+
/** Final ordered media collection; uploads complete before registration. */
|
|
230
|
+
media?: MediaItemT[]
|
|
231
|
+
/** Final Project and Source Context frozen at registration. */
|
|
232
|
+
project?: string
|
|
233
|
+
source?: SourceContextT
|
|
213
234
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
214
235
|
request_id?: string
|
|
215
236
|
collapse_key?: string
|
|
@@ -251,12 +272,35 @@ function summarizeRequestIds(entries: readonly PendingQuestion[]): {
|
|
|
251
272
|
*/
|
|
252
273
|
export const WAITER_CEILING_SECONDS = 480
|
|
253
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The same wall clock where the waiter runs in the background instead of
|
|
277
|
+
* inside a held turn.
|
|
278
|
+
*
|
|
279
|
+
* On a host that holds its turn, 480 s is a real cost to the person sitting in
|
|
280
|
+
* front of it, and the number above is right. On Claude Code the Stop hook
|
|
281
|
+
* returns at once and the waiter is detached, so the same 480 s buys nothing
|
|
282
|
+
* and costs a direct wake: an answer arriving at minute nine had to wait for
|
|
283
|
+
* the session's next turn even though nothing was blocked.
|
|
284
|
+
*
|
|
285
|
+
* This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
|
|
286
|
+
* because that declaration's kill is silent, and under the reply window the
|
|
287
|
+
* question was pushed with. Past it the journal still delivers at the next
|
|
288
|
+
* turn — the ceiling decides how long a *direct* wake stays possible, never
|
|
289
|
+
* whether the answer survives.
|
|
290
|
+
*/
|
|
291
|
+
export const DETACHED_WAITER_CEILING_SECONDS = 3300
|
|
292
|
+
|
|
293
|
+
/** The waiter's wall clock, which depends on whether a turn is held open for it. */
|
|
294
|
+
export function waiterCeilingSeconds(detached: boolean): number {
|
|
295
|
+
return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS
|
|
296
|
+
}
|
|
297
|
+
|
|
254
298
|
/**
|
|
255
299
|
* The wire contract's shortest reply window. A question submitted with less
|
|
256
300
|
* than this is rejected outright, and accepting one would in any case let the
|
|
257
301
|
* server go on taking an answer after the waiter that owns it has returned.
|
|
258
302
|
*/
|
|
259
|
-
const MIN_REPLY_WINDOW_SECONDS = 60
|
|
303
|
+
export const MIN_REPLY_WINDOW_SECONDS = 60
|
|
260
304
|
|
|
261
305
|
/**
|
|
262
306
|
* The terminal-first wait: the question sits in the terminal for
|
|
@@ -469,6 +513,17 @@ export function pruneAbandonedSessions(
|
|
|
469
513
|
* no trustworthy PID fall back to an age limit.
|
|
470
514
|
*/
|
|
471
515
|
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* How long a crashed claim *guard* blocks the next hook.
|
|
519
|
+
*
|
|
520
|
+
* The claim itself may legitimately be held for the whole waiter, so it takes
|
|
521
|
+
* that ceiling. The guard is a short lock around the claim's own bookkeeping —
|
|
522
|
+
* a few file operations — and borrowing the waiter's ceiling for it meant a
|
|
523
|
+
* hook killed at exactly the wrong moment wedged this session's Stop path for
|
|
524
|
+
* eight minutes, for a critical section that never runs longer than a second.
|
|
525
|
+
*/
|
|
526
|
+
const CLAIM_GUARD_TTL_MS = 30_000
|
|
472
527
|
const heldClaims = new Map<string, string>()
|
|
473
528
|
|
|
474
529
|
export function claimQuestionPush(
|
|
@@ -562,7 +617,7 @@ function acquireClaimGuard(file: string): boolean {
|
|
|
562
617
|
} catch (err) {
|
|
563
618
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false
|
|
564
619
|
try {
|
|
565
|
-
if (Date.now() - statSync(file).mtimeMs <
|
|
620
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS) return false
|
|
566
621
|
} catch {
|
|
567
622
|
// A vanished/corrupt guard is safe to retry once.
|
|
568
623
|
}
|
|
@@ -788,19 +843,29 @@ export interface HookContext {
|
|
|
788
843
|
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
789
844
|
* filter cannot rely on is a log nobody filters.
|
|
790
845
|
*/
|
|
791
|
-
export
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
846
|
+
export const GATE_REASONS = [
|
|
847
|
+
'no-session',
|
|
848
|
+
'no-question',
|
|
849
|
+
'answered',
|
|
850
|
+
'continuation-repeat',
|
|
851
|
+
'continuation-limit',
|
|
852
|
+
'delivery-limit',
|
|
853
|
+
'acknowledgement-required',
|
|
854
|
+
'acknowledgement-abandoned',
|
|
855
|
+
'harness-cannot-continue',
|
|
856
|
+
'notifications-off',
|
|
857
|
+
'claimed-elsewhere',
|
|
858
|
+
'elapsed',
|
|
859
|
+
'proceeding',
|
|
860
|
+
] as const
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* A runtime list rather than a bare type, so the guidance that tells agents to
|
|
864
|
+
* filter on these can be tested against the set the code can actually emit.
|
|
865
|
+
* `already-asked` and `no-devices` were declared here for a long time and never
|
|
866
|
+
* emitted once; documenting them taught agents to filter for silence.
|
|
867
|
+
*/
|
|
868
|
+
export type GateReason = (typeof GATE_REASONS)[number]
|
|
804
869
|
|
|
805
870
|
function gate(
|
|
806
871
|
ctx: HookContext,
|
|
@@ -840,10 +905,10 @@ async function prepareQuestionSubmission(
|
|
|
840
905
|
title: string
|
|
841
906
|
body: string
|
|
842
907
|
questions: QuestionT[]
|
|
843
|
-
|
|
908
|
+
media?: MediaItemT[] | undefined
|
|
909
|
+
project?: string | undefined
|
|
910
|
+
source?: SourceContextT | undefined
|
|
844
911
|
event: string
|
|
845
|
-
/** Which agent is asking; two of them must not look alike. */
|
|
846
|
-
session?: string | undefined
|
|
847
912
|
/** How long the server keeps accepting an answer. */
|
|
848
913
|
windowSeconds: number
|
|
849
914
|
/** Absolute owner deadline persisted before submit. */
|
|
@@ -857,19 +922,23 @@ async function prepareQuestionSubmission(
|
|
|
857
922
|
if (answerable.length === 0) {
|
|
858
923
|
return { error: 'no device can answer a question yet; leaving this to the terminal' }
|
|
859
924
|
}
|
|
860
|
-
const build = buildDraft(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
925
|
+
const build = buildDraft(
|
|
926
|
+
ctx.config,
|
|
927
|
+
{
|
|
928
|
+
title: options.title,
|
|
929
|
+
body: options.body,
|
|
930
|
+
event: options.event,
|
|
931
|
+
lifecycle: { tier: 'needs_you' },
|
|
932
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
933
|
+
...(options.media !== undefined ? { media: options.media } : {}),
|
|
934
|
+
device: answerable,
|
|
935
|
+
reply: true,
|
|
936
|
+
replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
|
|
937
|
+
questions: options.questions,
|
|
938
|
+
collapseKey,
|
|
939
|
+
},
|
|
940
|
+
options.source === undefined ? {} : { source: options.source },
|
|
941
|
+
)
|
|
873
942
|
if (!build.ok) return { error: build.error }
|
|
874
943
|
|
|
875
944
|
return {
|
|
@@ -996,23 +1065,6 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
996
1065
|
.map((device) => device.device_id)
|
|
997
1066
|
}
|
|
998
1067
|
|
|
999
|
-
/**
|
|
1000
|
-
* The session id this push is attributed to — the same one `send` carries.
|
|
1001
|
-
*
|
|
1002
|
-
* The hook has always known `session_id` and never passed it on, so two agents
|
|
1003
|
-
* in separate worktrees produced identical notifications and the user could
|
|
1004
|
-
* answer the wrong one's question. An exported `NOTIFAI_SESSION`
|
|
1005
|
-
* still wins, for coherence rather than vanity: it is THE session id wherever
|
|
1006
|
-
* it is set, so a session that exported one before launching must carry
|
|
1007
|
-
* the same on its own sends and on the questions its hooks push. A name the
|
|
1008
|
-
* user chose also outlives harness restarts, which a per-launch UUID cannot.
|
|
1009
|
-
*/
|
|
1010
|
-
function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefined {
|
|
1011
|
-
const explicit = ctx.env['NOTIFAI_SESSION']
|
|
1012
|
-
if (explicit !== undefined && explicit !== '') return explicit
|
|
1013
|
-
return envelope.session_id
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
1068
|
/**
|
|
1017
1069
|
* Everything retirement needs. Narrower than a HookContext on purpose:
|
|
1018
1070
|
* `notifai ask` supersedes the previous question and it is a plain command with
|
|
@@ -1058,18 +1110,23 @@ async function retire(
|
|
|
1058
1110
|
endState: LifecycleEndState,
|
|
1059
1111
|
retiresRequestId: string,
|
|
1060
1112
|
devices?: string[],
|
|
1061
|
-
|
|
1113
|
+
project?: string | undefined,
|
|
1114
|
+
source?: SourceContextT | undefined,
|
|
1062
1115
|
): Promise<boolean> {
|
|
1063
|
-
const build = buildDraft(
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1116
|
+
const build = buildDraft(
|
|
1117
|
+
ctx.config,
|
|
1118
|
+
{
|
|
1119
|
+
title,
|
|
1120
|
+
body,
|
|
1121
|
+
event: 'question_retired',
|
|
1122
|
+
lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
|
|
1123
|
+
...(project !== undefined ? { project } : {}),
|
|
1124
|
+
...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
|
|
1125
|
+
collapseKey,
|
|
1126
|
+
level: 'passive',
|
|
1127
|
+
},
|
|
1128
|
+
source === undefined ? {} : { source },
|
|
1129
|
+
)
|
|
1073
1130
|
if (!build.ok) return false
|
|
1074
1131
|
try {
|
|
1075
1132
|
await ctx.client.submit(
|
|
@@ -1150,6 +1207,8 @@ function retiringQuestion(
|
|
|
1150
1207
|
collapse_key: pending.collapse_key!,
|
|
1151
1208
|
device_ids: [...pending.device_ids!],
|
|
1152
1209
|
question: pending.question,
|
|
1210
|
+
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1211
|
+
...(pending.source !== undefined ? { source: pending.source } : {}),
|
|
1153
1212
|
state,
|
|
1154
1213
|
}
|
|
1155
1214
|
}
|
|
@@ -1172,7 +1231,6 @@ export async function drainRetirements(
|
|
|
1172
1231
|
ctx: RetireDeps,
|
|
1173
1232
|
sessionId: string,
|
|
1174
1233
|
env: NodeJS.ProcessEnv,
|
|
1175
|
-
session?: string | undefined,
|
|
1176
1234
|
): Promise<string[]> {
|
|
1177
1235
|
const queue = readSessionState(sessionId, env).retiring ?? []
|
|
1178
1236
|
if (queue.length === 0) return []
|
|
@@ -1188,7 +1246,8 @@ export async function drainRetirements(
|
|
|
1188
1246
|
entry.state,
|
|
1189
1247
|
entry.request_id,
|
|
1190
1248
|
retirementDeviceIds(entry),
|
|
1191
|
-
|
|
1249
|
+
entry.project,
|
|
1250
|
+
entry.source,
|
|
1192
1251
|
)
|
|
1193
1252
|
if (sent) {
|
|
1194
1253
|
retired.push(entry.request_id)
|
|
@@ -1241,7 +1300,6 @@ function updateOrphanQueue(
|
|
|
1241
1300
|
export function orphanRetirements(
|
|
1242
1301
|
env: NodeJS.ProcessEnv,
|
|
1243
1302
|
entries: RetiringQuestion[],
|
|
1244
|
-
session: string | undefined,
|
|
1245
1303
|
now: number,
|
|
1246
1304
|
): void {
|
|
1247
1305
|
if (entries.length === 0) return
|
|
@@ -1249,11 +1307,7 @@ export function orphanRetirements(
|
|
|
1249
1307
|
const known = new Set(queue.map((entry) => entry.request_id))
|
|
1250
1308
|
const added = entries
|
|
1251
1309
|
.filter((entry) => !known.has(entry.request_id))
|
|
1252
|
-
.map((entry) => ({
|
|
1253
|
-
...entry,
|
|
1254
|
-
...(session !== undefined ? { session } : {}),
|
|
1255
|
-
enqueued_at: now,
|
|
1256
|
-
}))
|
|
1310
|
+
.map((entry) => ({ ...entry, enqueued_at: now }))
|
|
1257
1311
|
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
|
|
1258
1312
|
})
|
|
1259
1313
|
}
|
|
@@ -1291,7 +1345,8 @@ export async function drainOrphanRetirements(
|
|
|
1291
1345
|
entry.state,
|
|
1292
1346
|
entry.request_id,
|
|
1293
1347
|
retirementDeviceIds(entry),
|
|
1294
|
-
entry.
|
|
1348
|
+
entry.project,
|
|
1349
|
+
entry.source,
|
|
1295
1350
|
)
|
|
1296
1351
|
if (sent) {
|
|
1297
1352
|
done.push(entry.request_id)
|
|
@@ -1315,6 +1370,21 @@ const LATE_PROMPT_POLL_SECONDS = 3
|
|
|
1315
1370
|
*/
|
|
1316
1371
|
export const MAX_CONTINUATION_COUNT = 3
|
|
1317
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* How many turns an answer may be *held* before it is settled unread.
|
|
1375
|
+
*
|
|
1376
|
+
* A hold is not a delivery: the route reported that it handed nothing over, so
|
|
1377
|
+
* the agent has not seen the answer and the wake-loop the continuation cap
|
|
1378
|
+
* exists to stop has not happened. Counting holds against that cap meant three
|
|
1379
|
+
* turns where a liveness probe could not reach a busy session were enough to
|
|
1380
|
+
* discard an answer the user had already given.
|
|
1381
|
+
*
|
|
1382
|
+
* It still has to end. A route that can never deliver would otherwise retry
|
|
1383
|
+
* every turn for the life of the session, so holds get their own, far looser
|
|
1384
|
+
* bound — loose because each one costs nothing and the answer is still wanted.
|
|
1385
|
+
*/
|
|
1386
|
+
export const MAX_HELD_DELIVERIES = 20
|
|
1387
|
+
|
|
1318
1388
|
interface PendingPoll {
|
|
1319
1389
|
/** The answer to act on: the latest reply, because a later one corrects. */
|
|
1320
1390
|
reply: ReplyView | null
|
|
@@ -1355,6 +1425,8 @@ interface AnsweredPending {
|
|
|
1355
1425
|
replies: ReplyView[]
|
|
1356
1426
|
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
1357
1427
|
agent_acknowledgement_required?: boolean | undefined
|
|
1428
|
+
/** Whether that acknowledgement must carry text; the account's snapshot. */
|
|
1429
|
+
agent_acknowledgement_text_required?: boolean | undefined
|
|
1358
1430
|
}
|
|
1359
1431
|
|
|
1360
1432
|
interface FinalizedPending {
|
|
@@ -1387,6 +1459,8 @@ function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
|
1387
1459
|
replies,
|
|
1388
1460
|
agent_acknowledgement_required:
|
|
1389
1461
|
finalized.response?.agent_acknowledgement_required,
|
|
1462
|
+
agent_acknowledgement_text_required:
|
|
1463
|
+
finalized.response?.agent_acknowledgement_text_required,
|
|
1390
1464
|
}
|
|
1391
1465
|
}
|
|
1392
1466
|
|
|
@@ -1452,6 +1526,46 @@ function isSamePending(a: PendingQuestion, b: PendingQuestion): boolean {
|
|
|
1452
1526
|
return a.question === b.question && a.asked_at === b.asked_at
|
|
1453
1527
|
}
|
|
1454
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* The frozen draft itself will never be accepted. Replaying it forever hides a
|
|
1531
|
+
* contract-skew (deleted fields, unexpected properties) as a recoverable wait.
|
|
1532
|
+
* Auth, conflicts, and timeouts are not this class.
|
|
1533
|
+
*/
|
|
1534
|
+
function isTerminalDraftRejection(err: ApiCallError): boolean {
|
|
1535
|
+
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request')
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function dropPendingQuestion(
|
|
1539
|
+
sessionId: string,
|
|
1540
|
+
env: NodeJS.ProcessEnv,
|
|
1541
|
+
entry: PendingQuestion,
|
|
1542
|
+
): void {
|
|
1543
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1544
|
+
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry))
|
|
1545
|
+
const next: SessionState = { ...current }
|
|
1546
|
+
if (pending.length > 0) next.pending = pending
|
|
1547
|
+
else delete next.pending
|
|
1548
|
+
return next
|
|
1549
|
+
})
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function clearFrozenSubmission(
|
|
1553
|
+
sessionId: string,
|
|
1554
|
+
env: NodeJS.ProcessEnv,
|
|
1555
|
+
entry: PendingQuestion,
|
|
1556
|
+
): void {
|
|
1557
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1558
|
+
const list = pendingList(current)
|
|
1559
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
1560
|
+
if (index < 0) return current
|
|
1561
|
+
const next = [...list]
|
|
1562
|
+
const copy = { ...next[index]! }
|
|
1563
|
+
delete copy.submission
|
|
1564
|
+
next[index] = copy
|
|
1565
|
+
return { ...current, pending: next }
|
|
1566
|
+
})
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1455
1569
|
/** The question set this pending record pushes, however it was registered. */
|
|
1456
1570
|
function pendingQuestions(pending: PendingQuestion): QuestionT[] {
|
|
1457
1571
|
return pending.questions ?? [{ id: 'q1', text: pending.question }]
|
|
@@ -1499,8 +1613,20 @@ function answerContext(answered: AnsweredPending): string {
|
|
|
1499
1613
|
* tied to the question that asked it, with a truthful note about anything
|
|
1500
1614
|
* still waiting.
|
|
1501
1615
|
*/
|
|
1502
|
-
function acknowledgementCommand(requestId: string): string {
|
|
1503
|
-
return
|
|
1616
|
+
function acknowledgementCommand(requestId: string, textRequired = true): string {
|
|
1617
|
+
return textRequired
|
|
1618
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1619
|
+
: `notifai acknowledge ${requestId}`
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* The acknowledgement is owed either way; only its text is conditional. So the
|
|
1624
|
+
* instruction never says "you may skip this" — it says what to run.
|
|
1625
|
+
*/
|
|
1626
|
+
function acknowledgementDemand(textRequired: boolean): string {
|
|
1627
|
+
return textRequired
|
|
1628
|
+
? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
|
|
1629
|
+
: ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words'
|
|
1504
1630
|
}
|
|
1505
1631
|
|
|
1506
1632
|
function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
@@ -1512,20 +1638,23 @@ function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
|
1512
1638
|
return ' Agent Acknowledgement is not required for the answered request(s).'
|
|
1513
1639
|
}
|
|
1514
1640
|
if (due.length === 1) {
|
|
1515
|
-
const
|
|
1641
|
+
const entry = due[0]!
|
|
1642
|
+
const requestId = entry.pending.request_id!
|
|
1643
|
+
const textRequired = entry.agent_acknowledgement_text_required !== false
|
|
1516
1644
|
return (
|
|
1517
1645
|
` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
1518
|
-
`\`${acknowledgementCommand(requestId)}
|
|
1646
|
+
`\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`
|
|
1519
1647
|
)
|
|
1520
1648
|
}
|
|
1649
|
+
const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false)
|
|
1521
1650
|
const commands = due
|
|
1522
1651
|
.map((entry) => {
|
|
1523
1652
|
const requestId = entry.pending.request_id!
|
|
1524
|
-
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
|
|
1653
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``
|
|
1525
1654
|
})
|
|
1526
1655
|
.join('\n')
|
|
1527
1656
|
return (
|
|
1528
|
-
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below
|
|
1657
|
+
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below${acknowledgementDemand(anyTextRequired)}:\n` +
|
|
1529
1658
|
commands
|
|
1530
1659
|
)
|
|
1531
1660
|
}
|
|
@@ -1588,7 +1717,11 @@ function stageAcceptedAnswers(
|
|
|
1588
1717
|
requestId !== undefined &&
|
|
1589
1718
|
!acknowledgementDue.some((entry) => entry.request_id === requestId)
|
|
1590
1719
|
) {
|
|
1591
|
-
acknowledgementDue.push({
|
|
1720
|
+
acknowledgementDue.push({
|
|
1721
|
+
request_id: requestId,
|
|
1722
|
+
recorded_at: accepted.recorded_at,
|
|
1723
|
+
text_required: answer.agent_acknowledgement_text_required !== false,
|
|
1724
|
+
})
|
|
1592
1725
|
}
|
|
1593
1726
|
}
|
|
1594
1727
|
const next: SessionState = {
|
|
@@ -1636,14 +1769,83 @@ export function clearAcknowledgementObligation(
|
|
|
1636
1769
|
|
|
1637
1770
|
function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
|
|
1638
1771
|
const commands = due
|
|
1639
|
-
.map(
|
|
1772
|
+
.map(
|
|
1773
|
+
(entry) =>
|
|
1774
|
+
`- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``,
|
|
1775
|
+
)
|
|
1640
1776
|
.join('\n')
|
|
1777
|
+
const anyTextRequired = due.some((entry) => entry.text_required !== false)
|
|
1641
1778
|
return (
|
|
1642
1779
|
`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1643
|
-
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}
|
|
1780
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`
|
|
1644
1781
|
)
|
|
1645
1782
|
}
|
|
1646
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* How many turns in a row a session may be held waiting for an acknowledgement.
|
|
1786
|
+
*
|
|
1787
|
+
* The gate is the one place hooks deliberately break the fail-open rule, and it
|
|
1788
|
+
* had no bound at all: an agent that could not or would not acknowledge — or a
|
|
1789
|
+
* server that stayed unreachable, since an error here counts as unresolved —
|
|
1790
|
+
* held every turn of that session for ever. Blocking a user's agent
|
|
1791
|
+
* indefinitely does not get them an acknowledgement; it costs them the agent as
|
|
1792
|
+
* well as the acknowledgement.
|
|
1793
|
+
*/
|
|
1794
|
+
const MAX_ACKNOWLEDGEMENT_BLOCKS = 3
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Hold the turn for an outstanding acknowledgement, or give up and let it
|
|
1798
|
+
* through once holding has stopped being worth its cost.
|
|
1799
|
+
*
|
|
1800
|
+
* Returns the outcome that blocks the turn, or `null` to carry on.
|
|
1801
|
+
*/
|
|
1802
|
+
function holdForAcknowledgement(
|
|
1803
|
+
ctx: HookContext,
|
|
1804
|
+
sessionId: string,
|
|
1805
|
+
due: readonly AcknowledgementDue[],
|
|
1806
|
+
notes: string[],
|
|
1807
|
+
): HookOutcome | null {
|
|
1808
|
+
if (due.length === 0) return null
|
|
1809
|
+
const requestIds = due.map((entry) => entry.request_id)
|
|
1810
|
+
const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1
|
|
1811
|
+
if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
|
|
1812
|
+
// Drop the obligation with the reason recorded. The answer was already
|
|
1813
|
+
// delivered; what is lost is the agent's receipt for it, and the log is
|
|
1814
|
+
// where that loss stays visible.
|
|
1815
|
+
for (const requestId of requestIds) clearAcknowledgementObligation(sessionId, ctx.env, requestId)
|
|
1816
|
+
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
1817
|
+
gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
|
|
1818
|
+
request_ids: requestIds,
|
|
1819
|
+
blocks: blocks - 1,
|
|
1820
|
+
limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
|
|
1821
|
+
})
|
|
1822
|
+
notes.push(
|
|
1823
|
+
`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`,
|
|
1824
|
+
)
|
|
1825
|
+
return null
|
|
1826
|
+
}
|
|
1827
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1828
|
+
...current,
|
|
1829
|
+
acknowledgement_blocks: blocks,
|
|
1830
|
+
}))
|
|
1831
|
+
gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks })
|
|
1832
|
+
return {
|
|
1833
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1834
|
+
notes,
|
|
1835
|
+
log: { stage: 'acknowledgement-required', request_ids: requestIds },
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** A turn that was not held resets the streak; only consecutive holds count. */
|
|
1840
|
+
function resetAcknowledgementBlocks(sessionId: string, env: NodeJS.ProcessEnv): void {
|
|
1841
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1842
|
+
if (current.acknowledgement_blocks === undefined) return current
|
|
1843
|
+
const next = { ...current }
|
|
1844
|
+
delete next.acknowledgement_blocks
|
|
1845
|
+
return next
|
|
1846
|
+
})
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1647
1849
|
async function reconcileAcknowledgementObligations(
|
|
1648
1850
|
ctx: HookContext,
|
|
1649
1851
|
sessionId: string,
|
|
@@ -1803,6 +2005,9 @@ export async function handleUserPromptSubmit(
|
|
|
1803
2005
|
agent_acknowledgement_required:
|
|
1804
2006
|
response?.agent_acknowledgement_required ??
|
|
1805
2007
|
entry.agent_acknowledgement_required,
|
|
2008
|
+
agent_acknowledgement_text_required:
|
|
2009
|
+
response?.agent_acknowledgement_text_required ??
|
|
2010
|
+
entry.agent_acknowledgement_text_required,
|
|
1806
2011
|
}
|
|
1807
2012
|
})
|
|
1808
2013
|
if (answered.length > 0) {
|
|
@@ -1841,6 +2046,16 @@ export async function handleUserPromptSubmit(
|
|
|
1841
2046
|
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1842
2047
|
...(retiring.length > 0 ? { retiring } : {}),
|
|
1843
2048
|
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
2049
|
+
// Carried across a prompt on purpose. This rewrite rebuilds the state
|
|
2050
|
+
// from named fields, so anything omitted is silently erased — and an
|
|
2051
|
+
// erased obligation means the user never learns their reply was read,
|
|
2052
|
+
// which is the one thing acknowledgement exists to guarantee.
|
|
2053
|
+
...(current.acknowledgement_due === undefined
|
|
2054
|
+
? {}
|
|
2055
|
+
: { acknowledgement_due: current.acknowledgement_due }),
|
|
2056
|
+
...(current.acknowledgement_blocks === undefined
|
|
2057
|
+
? {}
|
|
2058
|
+
: { acknowledgement_blocks: current.acknowledgement_blocks }),
|
|
1844
2059
|
// The user typed, so whatever chain of answer-driven continuations was
|
|
1845
2060
|
// running, a human has taken the turn and the consecutive count starts
|
|
1846
2061
|
// over. Only a real prompt reaches here: the journal branch above returns
|
|
@@ -1865,7 +2080,7 @@ export async function handleUserPromptSubmit(
|
|
|
1865
2080
|
notes.push('the late device answer will continue the agent at this turn’s Stop')
|
|
1866
2081
|
return { notes }
|
|
1867
2082
|
}
|
|
1868
|
-
const retired = await drainRetirements(ctx, sessionId, ctx.env
|
|
2083
|
+
const retired = await drainRetirements(ctx, sessionId, ctx.env)
|
|
1869
2084
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1870
2085
|
const swept = [...retired, ...orphaned]
|
|
1871
2086
|
if (swept.length > 0) {
|
|
@@ -1990,19 +2205,8 @@ export async function runEscalationWaiter(
|
|
|
1990
2205
|
currentAcceptedIds.has(entry.request_id),
|
|
1991
2206
|
),
|
|
1992
2207
|
)
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
1996
|
-
})
|
|
1997
|
-
return {
|
|
1998
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1999
|
-
notes,
|
|
2000
|
-
log: {
|
|
2001
|
-
stage: 'acknowledgement-required',
|
|
2002
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2003
|
-
},
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2208
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2209
|
+
if (held !== null) return held
|
|
2006
2210
|
state = readSessionState(sessionId, ctx.env)
|
|
2007
2211
|
}
|
|
2008
2212
|
|
|
@@ -2012,26 +2216,16 @@ export async function runEscalationWaiter(
|
|
|
2012
2216
|
sessionId,
|
|
2013
2217
|
state.acknowledgement_due!,
|
|
2014
2218
|
)
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
})
|
|
2019
|
-
return {
|
|
2020
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2021
|
-
notes,
|
|
2022
|
-
log: {
|
|
2023
|
-
stage: 'acknowledgement-required',
|
|
2024
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
2025
|
-
},
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2219
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes)
|
|
2220
|
+
if (held !== null) return held
|
|
2221
|
+
resetAcknowledgementBlocks(sessionId, ctx.env)
|
|
2028
2222
|
state = readSessionState(sessionId, ctx.env)
|
|
2029
2223
|
}
|
|
2030
2224
|
|
|
2031
2225
|
const pending = pendingList(state)
|
|
2032
2226
|
if (pending.length === 0) {
|
|
2033
2227
|
const swept = [
|
|
2034
|
-
...(await drainRetirements(ctx, sessionId, ctx.env
|
|
2228
|
+
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
2035
2229
|
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
2036
2230
|
]
|
|
2037
2231
|
if (swept.length > 0) {
|
|
@@ -2095,6 +2289,20 @@ async function deliverAcceptedAnswers(
|
|
|
2095
2289
|
): Promise<HookOutcome> {
|
|
2096
2290
|
const { answers: answered, remaining } = accepted
|
|
2097
2291
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
2292
|
+
const held = accepted.held_deliveries ?? 0
|
|
2293
|
+
if (held >= MAX_HELD_DELIVERIES) {
|
|
2294
|
+
gate(ctx, 'held', 'delivery-limit', {
|
|
2295
|
+
route: route.kind,
|
|
2296
|
+
held,
|
|
2297
|
+
limit: MAX_HELD_DELIVERIES,
|
|
2298
|
+
request_ids: requestIds,
|
|
2299
|
+
})
|
|
2300
|
+
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2301
|
+
notes.push(
|
|
2302
|
+
`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
|
|
2303
|
+
)
|
|
2304
|
+
return { notes }
|
|
2305
|
+
}
|
|
2098
2306
|
const attempt = (accepted.delivery_attempts ?? 0) + 1
|
|
2099
2307
|
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
2100
2308
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -2145,6 +2353,17 @@ async function deliverAcceptedAnswers(
|
|
|
2145
2353
|
delivered_at: ctx.now(),
|
|
2146
2354
|
delivered_route: deliveredRoute,
|
|
2147
2355
|
}))
|
|
2356
|
+
} else if (delivered.acknowledgement === 'held') {
|
|
2357
|
+
// Give the delivery attempt back and count the hold instead. The attempt
|
|
2358
|
+
// was counted before the call because one that dies mid-delivery still
|
|
2359
|
+
// happened and a counter written afterwards could be evaded by crashing —
|
|
2360
|
+
// but a route that *returns* `held` proves both that this process survived
|
|
2361
|
+
// and that nothing was handed over.
|
|
2362
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
2363
|
+
...current,
|
|
2364
|
+
delivery_attempts: accepted.delivery_attempts ?? 0,
|
|
2365
|
+
held_deliveries: (current.held_deliveries ?? 0) + 1,
|
|
2366
|
+
}))
|
|
2148
2367
|
}
|
|
2149
2368
|
ctx.log?.info('hook.answer', {
|
|
2150
2369
|
answered: true,
|
|
@@ -2185,7 +2404,11 @@ async function handleClaimedStop(
|
|
|
2185
2404
|
) {
|
|
2186
2405
|
for (const entry of live) await closeQuietly(ctx, entry.request_id!)
|
|
2187
2406
|
await retirePendings(ctx, envelope, sessionId, pending, 'expired')
|
|
2188
|
-
|
|
2407
|
+
// Not `no-question`: there was a question, and this says so. Reading the
|
|
2408
|
+
// log to find out why an ask never travelled is the whole reason these
|
|
2409
|
+
// reasons exist, and the wrong one sent that reader looking for a question
|
|
2410
|
+
// that was right there.
|
|
2411
|
+
gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness })
|
|
2189
2412
|
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`)
|
|
2190
2413
|
return { notes }
|
|
2191
2414
|
}
|
|
@@ -2227,6 +2450,9 @@ async function handleClaimedStop(
|
|
|
2227
2450
|
agent_acknowledgement_required:
|
|
2228
2451
|
response?.agent_acknowledgement_required ??
|
|
2229
2452
|
entry.agent_acknowledgement_required,
|
|
2453
|
+
agent_acknowledgement_text_required:
|
|
2454
|
+
response?.agent_acknowledgement_text_required ??
|
|
2455
|
+
entry.agent_acknowledgement_text_required,
|
|
2230
2456
|
}
|
|
2231
2457
|
})
|
|
2232
2458
|
const accepted = stageAcceptedAnswers(
|
|
@@ -2355,7 +2581,7 @@ async function escalate(
|
|
|
2355
2581
|
await awaitTerminalFirstWindow(ctx, oldest, ceilingAt)
|
|
2356
2582
|
ctx.log?.debug('hook.gate', {
|
|
2357
2583
|
verdict: 'grace',
|
|
2358
|
-
reason: 'elapsed',
|
|
2584
|
+
reason: 'elapsed' satisfies GateReason,
|
|
2359
2585
|
waited_from: oldest,
|
|
2360
2586
|
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
2361
2587
|
})
|
|
@@ -2364,35 +2590,42 @@ async function escalate(
|
|
|
2364
2590
|
// its own notification — one ask never stands in for another.
|
|
2365
2591
|
const submitted: PendingQuestion[] = []
|
|
2366
2592
|
for (const entry of unasked) {
|
|
2367
|
-
//
|
|
2368
|
-
//
|
|
2369
|
-
//
|
|
2370
|
-
|
|
2371
|
-
|
|
2593
|
+
// How long the answer is accepted, and how long this owner listens for it,
|
|
2594
|
+
// are two different spans. They used to be one: the window was whatever
|
|
2595
|
+
// remained of the waiter's ceiling, so a question asked at a turn's end
|
|
2596
|
+
// stopped being answerable about eight minutes later, whatever the user
|
|
2597
|
+
// had configured.
|
|
2598
|
+
//
|
|
2599
|
+
// That was right when the waiter was the only way an answer came back. It
|
|
2600
|
+
// is not any more: a late answer is found by the next turn's poll or
|
|
2601
|
+
// replayed from the journal, so this owner giving up listening says
|
|
2602
|
+
// nothing about whether the user's answer is still wanted.
|
|
2603
|
+
const replyWindowSeconds = ctx.config.reply_window_seconds.value
|
|
2604
|
+
if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
|
|
2372
2605
|
notes.push(
|
|
2373
|
-
'too little of the waiter ceiling is left
|
|
2606
|
+
'too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal',
|
|
2374
2607
|
)
|
|
2375
2608
|
continue
|
|
2376
2609
|
}
|
|
2610
|
+
// The owner still stops listening at its own ceiling. Only the span the
|
|
2611
|
+
// server accepts answers into changed.
|
|
2377
2612
|
const ownerDeadlineAt = ceilingAt
|
|
2378
2613
|
const questions = pendingQuestions(entry)
|
|
2379
2614
|
let intent = entry.submission
|
|
2380
2615
|
if (intent === undefined) {
|
|
2616
|
+
if (entry.body === undefined) {
|
|
2617
|
+
notes.push('registered question has no canonical body; register it again with this CLI')
|
|
2618
|
+
continue
|
|
2619
|
+
}
|
|
2381
2620
|
const prepared = await prepareQuestionSubmission(ctx, {
|
|
2382
|
-
title
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
: `Question · ${ctx.config.project.value}`,
|
|
2386
|
-
// A set is answered on the expanded card; the banner leads with the
|
|
2387
|
-
// first question and says how much more is waiting behind it.
|
|
2388
|
-
body:
|
|
2389
|
-
questions.length > 1
|
|
2390
|
-
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
2391
|
-
: entry.question,
|
|
2621
|
+
// Type and Project have their own fields; the title is only substance.
|
|
2622
|
+
title: questions[0]!.text,
|
|
2623
|
+
body: entry.body,
|
|
2392
2624
|
questions,
|
|
2393
|
-
...(entry.
|
|
2625
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2626
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2627
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
2394
2628
|
event: 'agent_question',
|
|
2395
|
-
session: sessionLabel(ctx, envelope),
|
|
2396
2629
|
windowSeconds: replyWindowSeconds,
|
|
2397
2630
|
ownerDeadlineAt,
|
|
2398
2631
|
})
|
|
@@ -2444,12 +2677,80 @@ async function escalate(
|
|
|
2444
2677
|
try {
|
|
2445
2678
|
receipt = await submitQuestion(ctx, intent)
|
|
2446
2679
|
admissionConfirmed = true
|
|
2447
|
-
} catch (
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2680
|
+
} catch (caught) {
|
|
2681
|
+
let err: unknown = caught
|
|
2682
|
+
if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
|
|
2683
|
+
ctx.log?.error('hook.pushed', {
|
|
2684
|
+
ok: false,
|
|
2685
|
+
request_id: intent.request_id,
|
|
2686
|
+
status: err.status,
|
|
2687
|
+
code: err.code,
|
|
2688
|
+
message: err.message,
|
|
2689
|
+
})
|
|
2690
|
+
if (entry.submission !== undefined) {
|
|
2691
|
+
notes.push(
|
|
2692
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`,
|
|
2693
|
+
)
|
|
2694
|
+
clearFrozenSubmission(sessionId, ctx.env, entry)
|
|
2695
|
+
if (entry.body === undefined) {
|
|
2696
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2697
|
+
notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it')
|
|
2698
|
+
continue
|
|
2699
|
+
}
|
|
2700
|
+
const reminted = await prepareQuestionSubmission(ctx, {
|
|
2701
|
+
title: questions[0]!.text,
|
|
2702
|
+
body: entry.body,
|
|
2703
|
+
questions,
|
|
2704
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2705
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2706
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
2707
|
+
event: 'agent_question',
|
|
2708
|
+
windowSeconds: replyWindowSeconds,
|
|
2709
|
+
ownerDeadlineAt,
|
|
2710
|
+
})
|
|
2711
|
+
if ('error' in reminted) {
|
|
2712
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2713
|
+
notes.push(reminted.error)
|
|
2714
|
+
continue
|
|
2715
|
+
}
|
|
2716
|
+
intent = reminted
|
|
2717
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
2718
|
+
const list = pendingList(current)
|
|
2719
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry))
|
|
2720
|
+
if (index < 0) return current
|
|
2721
|
+
const next = [...list]
|
|
2722
|
+
next[index] = { ...next[index]!, submission: reminted }
|
|
2723
|
+
return { ...current, pending: next }
|
|
2724
|
+
})
|
|
2725
|
+
try {
|
|
2726
|
+
receipt = await submitQuestion(ctx, intent)
|
|
2727
|
+
admissionConfirmed = true
|
|
2728
|
+
} catch (retryErr) {
|
|
2729
|
+
if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
|
|
2730
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2731
|
+
notes.push(
|
|
2732
|
+
`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`,
|
|
2733
|
+
)
|
|
2734
|
+
ctx.log?.error('hook.pushed', {
|
|
2735
|
+
ok: false,
|
|
2736
|
+
request_id: intent.request_id,
|
|
2737
|
+
status: retryErr.status,
|
|
2738
|
+
code: retryErr.code,
|
|
2739
|
+
message: retryErr.message,
|
|
2740
|
+
})
|
|
2741
|
+
continue
|
|
2742
|
+
}
|
|
2743
|
+
err = retryErr
|
|
2744
|
+
}
|
|
2745
|
+
} else {
|
|
2746
|
+
dropPendingQuestion(sessionId, ctx.env, entry)
|
|
2747
|
+
notes.push(
|
|
2748
|
+
`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`,
|
|
2749
|
+
)
|
|
2750
|
+
continue
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
2453
2754
|
notes.push(
|
|
2454
2755
|
`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`,
|
|
2455
2756
|
)
|
|
@@ -2462,11 +2763,10 @@ async function escalate(
|
|
|
2462
2763
|
})
|
|
2463
2764
|
continue
|
|
2464
2765
|
}
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2766
|
+
if (!admissionConfirmed) {
|
|
2767
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`)
|
|
2768
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) })
|
|
2769
|
+
}
|
|
2470
2770
|
}
|
|
2471
2771
|
if (admissionConfirmed) {
|
|
2472
2772
|
ctx.log?.info('hook.pushed', {
|
|
@@ -2486,16 +2786,21 @@ async function escalate(
|
|
|
2486
2786
|
request_id: intent.request_id,
|
|
2487
2787
|
collapse_key: intent.collapse_key,
|
|
2488
2788
|
device_ids: intent.device_ids,
|
|
2489
|
-
//
|
|
2490
|
-
//
|
|
2491
|
-
//
|
|
2492
|
-
//
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
),
|
|
2789
|
+
// When the server stops accepting an answer — not when this owner stops
|
|
2790
|
+
// waiting for one. Those were the same number until the window became a
|
|
2791
|
+
// setting, and taking the minimum silently capped every question at the
|
|
2792
|
+
// waiter's ceiling: a question asked with a day-long window was swept as
|
|
2793
|
+
// stale about eight minutes later and retired, so the answer given over
|
|
2794
|
+
// lunch met a closed question.
|
|
2795
|
+
//
|
|
2796
|
+
// Both consumers of this field already clamp to the owner's own ceiling
|
|
2797
|
+
// (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
|
|
2798
|
+
// `Math.min(ceilingAt, …)` when deriving the wait), so recording the
|
|
2799
|
+
// real expiry here lengthens what the user gets without lengthening what
|
|
2800
|
+
// this process waits.
|
|
2801
|
+
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
2802
|
+
? committedReplyDeadline
|
|
2803
|
+
: intent.owner_deadline_at,
|
|
2499
2804
|
}
|
|
2500
2805
|
delete live.submission
|
|
2501
2806
|
submitted.push(live)
|
|
@@ -2583,10 +2888,37 @@ async function escalate(
|
|
|
2583
2888
|
if (permanentFailure !== null) notes.push(permanentFailure)
|
|
2584
2889
|
|
|
2585
2890
|
if (waited.byRequest.size === 0) {
|
|
2586
|
-
//
|
|
2587
|
-
//
|
|
2588
|
-
//
|
|
2589
|
-
|
|
2891
|
+
// This owner is done listening. That used to mean the question was over:
|
|
2892
|
+
// it closed the window server-side and retired the record, on the reasoning
|
|
2893
|
+
// that no later answer could reach the same agent turn.
|
|
2894
|
+
//
|
|
2895
|
+
// The reaching was never the point — the *accepting* is. A question whose
|
|
2896
|
+
// window is still open is polled again by the next prompt and the next
|
|
2897
|
+
// Stop, and an answer found there is journaled and replayed. Closing here
|
|
2898
|
+
// capped every question at this owner's ceiling however long a window the
|
|
2899
|
+
// user had configured, which is what made a day-long window mean eight
|
|
2900
|
+
// minutes.
|
|
2901
|
+
//
|
|
2902
|
+
// So: finalize only what the server has genuinely stopped accepting, and
|
|
2903
|
+
// leave the rest live for the turn that comes next. A session that truly
|
|
2904
|
+
// ends still retires its questions — that is what the session-end hook is
|
|
2905
|
+
// for, and it is the honest place for it.
|
|
2906
|
+
const expired = waitingOn.filter(
|
|
2907
|
+
(entry) =>
|
|
2908
|
+
entry.reply_deadline_at === undefined ||
|
|
2909
|
+
entry.reply_deadline_at <= ctx.now() ||
|
|
2910
|
+
// A permanent rejection is the server saying this question can never be
|
|
2911
|
+
// answered, whatever its window says. Leaving it open would strand a
|
|
2912
|
+
// dead question on the user's devices.
|
|
2913
|
+
waited.permanentFailures.has(entry.request_id!),
|
|
2914
|
+
)
|
|
2915
|
+
const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry))
|
|
2916
|
+
if (stillAnswerable.length > 0) {
|
|
2917
|
+
notes.push(
|
|
2918
|
+
`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' is' : 's are'} still answerable; leaving ${stillAnswerable.length === 1 ? 'it' : 'them'} open for the next turn rather than closing ${stillAnswerable.length === 1 ? 'it' : 'them'} with this waiter`,
|
|
2919
|
+
)
|
|
2920
|
+
}
|
|
2921
|
+
const finalized = await finalizePendings(ctx, expired)
|
|
2590
2922
|
const finalAnswers = finalized
|
|
2591
2923
|
.map(finalizedAnswer)
|
|
2592
2924
|
.filter((entry): entry is AnsweredPending => entry !== null)
|
|
@@ -2648,6 +2980,8 @@ async function escalate(
|
|
|
2648
2980
|
replies: authoritative,
|
|
2649
2981
|
agent_acknowledgement_required:
|
|
2650
2982
|
finalized.response?.agent_acknowledgement_required,
|
|
2983
|
+
agent_acknowledgement_text_required:
|
|
2984
|
+
finalized.response?.agent_acknowledgement_text_required,
|
|
2651
2985
|
})
|
|
2652
2986
|
if (finalized.response === null) {
|
|
2653
2987
|
notes.push(
|
|
@@ -2704,8 +3038,7 @@ export function handleSessionEnd(
|
|
|
2704
3038
|
}
|
|
2705
3039
|
}
|
|
2706
3040
|
if (orphans.length > 0) {
|
|
2707
|
-
|
|
2708
|
-
orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now)
|
|
3041
|
+
orphanRetirements(env, orphans, now)
|
|
2709
3042
|
notes.push(
|
|
2710
3043
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
2711
3044
|
)
|
|
@@ -2744,13 +3077,23 @@ export function handleSessionEnd(
|
|
|
2744
3077
|
// ---------------------------------------------------------------------------
|
|
2745
3078
|
|
|
2746
3079
|
/**
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
*
|
|
2750
|
-
*
|
|
3080
|
+
* Registrations still waiting for their first push. More than this in one turn
|
|
3081
|
+
* means an agent is looping, not asking, and related questions belong in one
|
|
3082
|
+
* `ask --form` instead.
|
|
3083
|
+
*
|
|
3084
|
+
* Deliberately counts only unasked entries. A question already on the user's
|
|
3085
|
+
* devices is waiting on a person, not on the agent, and an answer stays
|
|
3086
|
+
* accepted for a day — so counting live questions here would have turned a
|
|
3087
|
+
* patient user into a reason the agent could no longer ask anything.
|
|
2751
3088
|
*/
|
|
2752
3089
|
export const MAX_PENDING_QUESTIONS = 4
|
|
2753
3090
|
|
|
3091
|
+
/**
|
|
3092
|
+
* Everything this session has open at once, asked or not. The lock screen is
|
|
3093
|
+
* the shared resource being protected here, not the agent's turn.
|
|
3094
|
+
*/
|
|
3095
|
+
export const MAX_LIVE_QUESTIONS = 10
|
|
3096
|
+
|
|
2754
3097
|
/**
|
|
2755
3098
|
* A session may hold several registered questions at once: a new `ask` never
|
|
2756
3099
|
* ends an earlier one. Each reaches the user as its own notification and is
|
|
@@ -2769,11 +3112,16 @@ export function registerQuestion(
|
|
|
2769
3112
|
question: PendingQuestion,
|
|
2770
3113
|
now: number = Date.now(),
|
|
2771
3114
|
): void {
|
|
2772
|
-
let full =
|
|
3115
|
+
let full: 'unasked' | 'live' | null = null
|
|
2773
3116
|
updateSessionState(sessionId, env, (state) => {
|
|
2774
3117
|
const pending = pendingList(state)
|
|
2775
|
-
|
|
2776
|
-
|
|
3118
|
+
const unasked = pending.filter((entry) => entry.request_id === undefined)
|
|
3119
|
+
if (unasked.length >= MAX_PENDING_QUESTIONS) {
|
|
3120
|
+
full = 'unasked'
|
|
3121
|
+
return state
|
|
3122
|
+
}
|
|
3123
|
+
if (pending.length >= MAX_LIVE_QUESTIONS) {
|
|
3124
|
+
full = 'live'
|
|
2777
3125
|
return state
|
|
2778
3126
|
}
|
|
2779
3127
|
return {
|
|
@@ -2789,12 +3137,18 @@ export function registerQuestion(
|
|
|
2789
3137
|
],
|
|
2790
3138
|
}
|
|
2791
3139
|
})
|
|
2792
|
-
if (full) {
|
|
3140
|
+
if (full === 'unasked') {
|
|
2793
3141
|
throw new Error(
|
|
2794
3142
|
`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
2795
3143
|
'Combine related questions into one `notifai ask --form` instead of registering more.',
|
|
2796
3144
|
)
|
|
2797
3145
|
}
|
|
3146
|
+
if (full === 'live') {
|
|
3147
|
+
throw new Error(
|
|
3148
|
+
`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
3149
|
+
'Retire the ones you no longer need with `notifai close <request_id>` before asking another.',
|
|
3150
|
+
)
|
|
3151
|
+
}
|
|
2798
3152
|
}
|
|
2799
3153
|
|
|
2800
3154
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|