@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/dist/hooks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol';
|
|
4
5
|
import { ApiCallError, isRetryableReplyPollError, } from './client.js';
|
|
5
6
|
import { loadConfig, projectSessionPointerPath, sanitizeSessionId, sessionConfigPath, stateDir, } from './config.js';
|
|
6
7
|
import { buildDraft } from './send.js';
|
|
@@ -8,12 +9,15 @@ import { atomicWriteFileSync } from './atomic-file.js';
|
|
|
8
9
|
import { withFileLock } from './file-lock.js';
|
|
9
10
|
import { HARNESS_CAPABILITIES, HARNESSES, } from './harnesses.js';
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* A retirement is only worth syncing while the question it retires could still
|
|
13
|
+
* be answered, so this has to outlive the longest reply window a user can
|
|
14
|
+
* choose — it is derived from that bound rather than from a guess about how
|
|
15
|
+
* long questions stay open, which is exactly the assumption that broke when
|
|
16
|
+
* the window stopped being one hour. Past it the companion shows the question
|
|
17
|
+
* as dead on next open anyway. Also the backstop that keeps an unreachable
|
|
18
|
+
* server from growing the queue for ever.
|
|
15
19
|
*/
|
|
16
|
-
const ORPHAN_TTL_MS =
|
|
20
|
+
const ORPHAN_TTL_MS = (REPLY_MAX_WINDOW_SECONDS + 3600) * 1000;
|
|
17
21
|
/** More orphans than this means something is looping; keep the newest. */
|
|
18
22
|
const ORPHAN_QUEUE_CAP = 50;
|
|
19
23
|
function summarizeRequestIds(entries) {
|
|
@@ -30,12 +34,33 @@ function summarizeRequestIds(entries) {
|
|
|
30
34
|
* question is only how long that host is willing to wait in total.
|
|
31
35
|
*/
|
|
32
36
|
export const WAITER_CEILING_SECONDS = 480;
|
|
37
|
+
/**
|
|
38
|
+
* The same wall clock where the waiter runs in the background instead of
|
|
39
|
+
* inside a held turn.
|
|
40
|
+
*
|
|
41
|
+
* On a host that holds its turn, 480 s is a real cost to the person sitting in
|
|
42
|
+
* front of it, and the number above is right. On Claude Code the Stop hook
|
|
43
|
+
* returns at once and the waiter is detached, so the same 480 s buys nothing
|
|
44
|
+
* and costs a direct wake: an answer arriving at minute nine had to wait for
|
|
45
|
+
* the session's next turn even though nothing was blocked.
|
|
46
|
+
*
|
|
47
|
+
* This sits under `CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS` (3600) with headroom,
|
|
48
|
+
* because that declaration's kill is silent, and under the reply window the
|
|
49
|
+
* question was pushed with. Past it the journal still delivers at the next
|
|
50
|
+
* turn — the ceiling decides how long a *direct* wake stays possible, never
|
|
51
|
+
* whether the answer survives.
|
|
52
|
+
*/
|
|
53
|
+
export const DETACHED_WAITER_CEILING_SECONDS = 3300;
|
|
54
|
+
/** The waiter's wall clock, which depends on whether a turn is held open for it. */
|
|
55
|
+
export function waiterCeilingSeconds(detached) {
|
|
56
|
+
return detached ? DETACHED_WAITER_CEILING_SECONDS : WAITER_CEILING_SECONDS;
|
|
57
|
+
}
|
|
33
58
|
/**
|
|
34
59
|
* The wire contract's shortest reply window. A question submitted with less
|
|
35
60
|
* than this is rejected outright, and accepting one would in any case let the
|
|
36
61
|
* server go on taking an answer after the waiter that owns it has returned.
|
|
37
62
|
*/
|
|
38
|
-
const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
63
|
+
export const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
39
64
|
/**
|
|
40
65
|
* The terminal-first wait: the question sits in the terminal for
|
|
41
66
|
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
@@ -168,6 +193,16 @@ export function pruneAbandonedSessions(env, now = Date.now(), maxAgeMs = SESSION
|
|
|
168
193
|
* no trustworthy PID fall back to an age limit.
|
|
169
194
|
*/
|
|
170
195
|
const CLAIM_TTL_MS = WAITER_CEILING_SECONDS * 1000;
|
|
196
|
+
/**
|
|
197
|
+
* How long a crashed claim *guard* blocks the next hook.
|
|
198
|
+
*
|
|
199
|
+
* The claim itself may legitimately be held for the whole waiter, so it takes
|
|
200
|
+
* that ceiling. The guard is a short lock around the claim's own bookkeeping —
|
|
201
|
+
* a few file operations — and borrowing the waiter's ceiling for it meant a
|
|
202
|
+
* hook killed at exactly the wrong moment wedged this session's Stop path for
|
|
203
|
+
* eight minutes, for a critical section that never runs longer than a second.
|
|
204
|
+
*/
|
|
205
|
+
const CLAIM_GUARD_TTL_MS = 30_000;
|
|
171
206
|
const heldClaims = new Map();
|
|
172
207
|
export function claimQuestionPush(sessionId, env, now = Date.now(), beforeStaleReplace) {
|
|
173
208
|
const file = claimPath(sessionId, env);
|
|
@@ -266,7 +301,7 @@ function acquireClaimGuard(file) {
|
|
|
266
301
|
if (err.code !== 'EEXIST')
|
|
267
302
|
return false;
|
|
268
303
|
try {
|
|
269
|
-
if (Date.now() - statSync(file).mtimeMs <
|
|
304
|
+
if (Date.now() - statSync(file).mtimeMs < CLAIM_GUARD_TTL_MS)
|
|
270
305
|
return false;
|
|
271
306
|
}
|
|
272
307
|
catch {
|
|
@@ -400,6 +435,30 @@ function clearMatchingProjectSession(cwd, env, sessionId) {
|
|
|
400
435
|
})}\n`);
|
|
401
436
|
});
|
|
402
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Record one gate decision.
|
|
440
|
+
*
|
|
441
|
+
* `verdict` is what happened to the question and `reason` is the closed-set
|
|
442
|
+
* name of the rule that decided it, so an agent can ask "why was it held" with
|
|
443
|
+
* a filter rather than by matching on prose. The prose still exists — it is what
|
|
444
|
+
* the user sees in the terminal — but it is free to be rewritten, and a log a
|
|
445
|
+
* filter cannot rely on is a log nobody filters.
|
|
446
|
+
*/
|
|
447
|
+
export const GATE_REASONS = [
|
|
448
|
+
'no-session',
|
|
449
|
+
'no-question',
|
|
450
|
+
'answered',
|
|
451
|
+
'continuation-repeat',
|
|
452
|
+
'continuation-limit',
|
|
453
|
+
'delivery-limit',
|
|
454
|
+
'acknowledgement-required',
|
|
455
|
+
'acknowledgement-abandoned',
|
|
456
|
+
'harness-cannot-continue',
|
|
457
|
+
'notifications-off',
|
|
458
|
+
'claimed-elsewhere',
|
|
459
|
+
'elapsed',
|
|
460
|
+
'proceeding',
|
|
461
|
+
];
|
|
403
462
|
function gate(ctx, verdict, reason, data = {}) {
|
|
404
463
|
ctx.log?.info('hook.gate', { verdict, reason, stage: 'queued', ...data });
|
|
405
464
|
}
|
|
@@ -436,14 +495,14 @@ async function prepareQuestionSubmission(ctx, options) {
|
|
|
436
495
|
body: options.body,
|
|
437
496
|
event: options.event,
|
|
438
497
|
lifecycle: { tier: 'needs_you' },
|
|
439
|
-
...(options.
|
|
498
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
499
|
+
...(options.media !== undefined ? { media: options.media } : {}),
|
|
440
500
|
device: answerable,
|
|
441
501
|
reply: true,
|
|
442
|
-
replyWindow: Math.max(
|
|
502
|
+
replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
|
|
443
503
|
questions: options.questions,
|
|
444
|
-
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
445
504
|
collapseKey,
|
|
446
|
-
});
|
|
505
|
+
}, options.source === undefined ? {} : { source: options.source });
|
|
447
506
|
if (!build.ok)
|
|
448
507
|
return { error: build.error };
|
|
449
508
|
return {
|
|
@@ -545,23 +604,6 @@ async function answerableDevices(ctx) {
|
|
|
545
604
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
546
605
|
.map((device) => device.device_id);
|
|
547
606
|
}
|
|
548
|
-
/**
|
|
549
|
-
* The session id this push is attributed to — the same one `send` carries.
|
|
550
|
-
*
|
|
551
|
-
* The hook has always known `session_id` and never passed it on, so two agents
|
|
552
|
-
* in separate worktrees produced identical notifications and the user could
|
|
553
|
-
* answer the wrong one's question. An exported `NOTIFAI_SESSION`
|
|
554
|
-
* still wins, for coherence rather than vanity: it is THE session id wherever
|
|
555
|
-
* it is set, so a session that exported one before launching must carry
|
|
556
|
-
* the same on its own sends and on the questions its hooks push. A name the
|
|
557
|
-
* user chose also outlives harness restarts, which a per-launch UUID cannot.
|
|
558
|
-
*/
|
|
559
|
-
function sessionLabel(ctx, envelope) {
|
|
560
|
-
const explicit = ctx.env['NOTIFAI_SESSION'];
|
|
561
|
-
if (explicit !== undefined && explicit !== '')
|
|
562
|
-
return explicit;
|
|
563
|
-
return envelope.session_id;
|
|
564
|
-
}
|
|
565
607
|
/**
|
|
566
608
|
* Close is a server-side serialization fence: a successful response contains
|
|
567
609
|
* every reply that committed before the window closed. `null` is deliberately
|
|
@@ -588,17 +630,17 @@ async function closeQuietly(ctx, requestId) {
|
|
|
588
630
|
* question simply waits until next open, which beats a tombstone banner
|
|
589
631
|
* announcing what the user just did.
|
|
590
632
|
*/
|
|
591
|
-
async function retire(ctx, collapseKey, title, body, endState, retiresRequestId, devices,
|
|
633
|
+
async function retire(ctx, collapseKey, title, body, endState, retiresRequestId, devices, project, source) {
|
|
592
634
|
const build = buildDraft(ctx.config, {
|
|
593
635
|
title,
|
|
594
636
|
body,
|
|
595
637
|
event: 'question_retired',
|
|
596
638
|
lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
|
|
597
|
-
...(
|
|
639
|
+
...(project !== undefined ? { project } : {}),
|
|
598
640
|
...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
|
|
599
641
|
collapseKey,
|
|
600
642
|
level: 'passive',
|
|
601
|
-
});
|
|
643
|
+
}, source === undefined ? {} : { source });
|
|
602
644
|
if (!build.ok)
|
|
603
645
|
return false;
|
|
604
646
|
try {
|
|
@@ -668,6 +710,8 @@ function retiringQuestion(pending, state) {
|
|
|
668
710
|
collapse_key: pending.collapse_key,
|
|
669
711
|
device_ids: [...pending.device_ids],
|
|
670
712
|
question: pending.question,
|
|
713
|
+
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
714
|
+
...(pending.source !== undefined ? { source: pending.source } : {}),
|
|
671
715
|
state,
|
|
672
716
|
};
|
|
673
717
|
}
|
|
@@ -682,14 +726,14 @@ function retirementDeviceIds(entry) {
|
|
|
682
726
|
* leaves the entry in place for the next hook rather than losing it, which is
|
|
683
727
|
* the whole reason the queue exists.
|
|
684
728
|
*/
|
|
685
|
-
export async function drainRetirements(ctx, sessionId, env
|
|
729
|
+
export async function drainRetirements(ctx, sessionId, env) {
|
|
686
730
|
const queue = readSessionState(sessionId, env).retiring ?? [];
|
|
687
731
|
if (queue.length === 0)
|
|
688
732
|
return [];
|
|
689
733
|
const retired = [];
|
|
690
734
|
for (const entry of queue) {
|
|
691
735
|
await closeQuietly(ctx, entry.request_id);
|
|
692
|
-
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry),
|
|
736
|
+
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.project, entry.source);
|
|
693
737
|
if (sent) {
|
|
694
738
|
retired.push(entry.request_id);
|
|
695
739
|
// Persist each success before touching the next entry. A later corrupt
|
|
@@ -730,18 +774,14 @@ function updateOrphanQueue(env, update) {
|
|
|
730
774
|
});
|
|
731
775
|
}
|
|
732
776
|
/** Move retirements into the global queue; deduped so a retry costs nothing. */
|
|
733
|
-
export function orphanRetirements(env, entries,
|
|
777
|
+
export function orphanRetirements(env, entries, now) {
|
|
734
778
|
if (entries.length === 0)
|
|
735
779
|
return;
|
|
736
780
|
updateOrphanQueue(env, (queue) => {
|
|
737
781
|
const known = new Set(queue.map((entry) => entry.request_id));
|
|
738
782
|
const added = entries
|
|
739
783
|
.filter((entry) => !known.has(entry.request_id))
|
|
740
|
-
.map((entry) => ({
|
|
741
|
-
...entry,
|
|
742
|
-
...(session !== undefined ? { session } : {}),
|
|
743
|
-
enqueued_at: now,
|
|
744
|
-
}));
|
|
784
|
+
.map((entry) => ({ ...entry, enqueued_at: now }));
|
|
745
785
|
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP);
|
|
746
786
|
});
|
|
747
787
|
}
|
|
@@ -764,7 +804,7 @@ export async function drainOrphanRetirements(ctx, env, now) {
|
|
|
764
804
|
continue;
|
|
765
805
|
}
|
|
766
806
|
await closeQuietly(ctx, entry.request_id);
|
|
767
|
-
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.
|
|
807
|
+
const sent = await retire(ctx, entry.collapse_key, RETIREMENT_TITLES[entry.state], entry.question, entry.state, entry.request_id, retirementDeviceIds(entry), entry.project, entry.source);
|
|
768
808
|
if (sent) {
|
|
769
809
|
done.push(entry.request_id);
|
|
770
810
|
updateOrphanQueue(env, (current) => current.filter((candidate) => candidate.request_id !== entry.request_id));
|
|
@@ -783,6 +823,20 @@ const LATE_PROMPT_POLL_SECONDS = 3;
|
|
|
783
823
|
* produce.
|
|
784
824
|
*/
|
|
785
825
|
export const MAX_CONTINUATION_COUNT = 3;
|
|
826
|
+
/**
|
|
827
|
+
* How many turns an answer may be *held* before it is settled unread.
|
|
828
|
+
*
|
|
829
|
+
* A hold is not a delivery: the route reported that it handed nothing over, so
|
|
830
|
+
* the agent has not seen the answer and the wake-loop the continuation cap
|
|
831
|
+
* exists to stop has not happened. Counting holds against that cap meant three
|
|
832
|
+
* turns where a liveness probe could not reach a busy session were enough to
|
|
833
|
+
* discard an answer the user had already given.
|
|
834
|
+
*
|
|
835
|
+
* It still has to end. A route that can never deliver would otherwise retry
|
|
836
|
+
* every turn for the life of the session, so holds get their own, far looser
|
|
837
|
+
* bound — loose because each one costs nothing and the answer is still wanted.
|
|
838
|
+
*/
|
|
839
|
+
export const MAX_HELD_DELIVERIES = 20;
|
|
786
840
|
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
787
841
|
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
788
842
|
if (pending.request_id === undefined) {
|
|
@@ -821,6 +875,7 @@ function finalizedAnswer(finalized) {
|
|
|
821
875
|
reply,
|
|
822
876
|
replies,
|
|
823
877
|
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
878
|
+
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
824
879
|
};
|
|
825
880
|
}
|
|
826
881
|
/** Persist and surface one answer without letting the two representations drift. */
|
|
@@ -867,6 +922,38 @@ function isSamePending(a, b) {
|
|
|
867
922
|
}
|
|
868
923
|
return a.question === b.question && a.asked_at === b.asked_at;
|
|
869
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* The frozen draft itself will never be accepted. Replaying it forever hides a
|
|
927
|
+
* contract-skew (deleted fields, unexpected properties) as a recoverable wait.
|
|
928
|
+
* Auth, conflicts, and timeouts are not this class.
|
|
929
|
+
*/
|
|
930
|
+
function isTerminalDraftRejection(err) {
|
|
931
|
+
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
|
|
932
|
+
}
|
|
933
|
+
function dropPendingQuestion(sessionId, env, entry) {
|
|
934
|
+
updateSessionState(sessionId, env, (current) => {
|
|
935
|
+
const pending = pendingList(current).filter((candidate) => !isSamePending(candidate, entry));
|
|
936
|
+
const next = { ...current };
|
|
937
|
+
if (pending.length > 0)
|
|
938
|
+
next.pending = pending;
|
|
939
|
+
else
|
|
940
|
+
delete next.pending;
|
|
941
|
+
return next;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
function clearFrozenSubmission(sessionId, env, entry) {
|
|
945
|
+
updateSessionState(sessionId, env, (current) => {
|
|
946
|
+
const list = pendingList(current);
|
|
947
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
948
|
+
if (index < 0)
|
|
949
|
+
return current;
|
|
950
|
+
const next = [...list];
|
|
951
|
+
const copy = { ...next[index] };
|
|
952
|
+
delete copy.submission;
|
|
953
|
+
next[index] = copy;
|
|
954
|
+
return { ...current, pending: next };
|
|
955
|
+
});
|
|
956
|
+
}
|
|
870
957
|
/** The question set this pending record pushes, however it was registered. */
|
|
871
958
|
function pendingQuestions(pending) {
|
|
872
959
|
return pending.questions ?? [{ id: 'q1', text: pending.question }];
|
|
@@ -909,8 +996,19 @@ function answerContext(answered) {
|
|
|
909
996
|
* tied to the question that asked it, with a truthful note about anything
|
|
910
997
|
* still waiting.
|
|
911
998
|
*/
|
|
912
|
-
function acknowledgementCommand(requestId) {
|
|
913
|
-
return
|
|
999
|
+
function acknowledgementCommand(requestId, textRequired = true) {
|
|
1000
|
+
return textRequired
|
|
1001
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1002
|
+
: `notifai acknowledge ${requestId}`;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* The acknowledgement is owed either way; only its text is conditional. So the
|
|
1006
|
+
* instruction never says "you may skip this" — it says what to run.
|
|
1007
|
+
*/
|
|
1008
|
+
function acknowledgementDemand(textRequired) {
|
|
1009
|
+
return textRequired
|
|
1010
|
+
? ' with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient'
|
|
1011
|
+
: ' exactly as shown; this account turned acknowledgement text off, so the receipt carries no words';
|
|
914
1012
|
}
|
|
915
1013
|
function acknowledgementContext(answered) {
|
|
916
1014
|
const due = answered.filter((entry) => entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined);
|
|
@@ -918,17 +1016,20 @@ function acknowledgementContext(answered) {
|
|
|
918
1016
|
return ' Agent Acknowledgement is not required for the answered request(s).';
|
|
919
1017
|
}
|
|
920
1018
|
if (due.length === 1) {
|
|
921
|
-
const
|
|
1019
|
+
const entry = due[0];
|
|
1020
|
+
const requestId = entry.pending.request_id;
|
|
1021
|
+
const textRequired = entry.agent_acknowledgement_text_required !== false;
|
|
922
1022
|
return (` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
923
|
-
`\`${acknowledgementCommand(requestId)}
|
|
1023
|
+
`\`${acknowledgementCommand(requestId, textRequired)}\`${acknowledgementDemand(textRequired)}.`);
|
|
924
1024
|
}
|
|
1025
|
+
const anyTextRequired = due.some((entry) => entry.agent_acknowledgement_text_required !== false);
|
|
925
1026
|
const commands = due
|
|
926
1027
|
.map((entry) => {
|
|
927
1028
|
const requestId = entry.pending.request_id;
|
|
928
|
-
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``;
|
|
1029
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId, entry.agent_acknowledgement_text_required !== false)}\``;
|
|
929
1030
|
})
|
|
930
1031
|
.join('\n');
|
|
931
|
-
return (` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below
|
|
1032
|
+
return (` 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` +
|
|
932
1033
|
commands);
|
|
933
1034
|
}
|
|
934
1035
|
function answersContext(answered, remaining) {
|
|
@@ -976,7 +1077,11 @@ function stageAcceptedAnswers(ctx, sessionId, answered, remaining) {
|
|
|
976
1077
|
if (answer.agent_acknowledgement_required === true &&
|
|
977
1078
|
requestId !== undefined &&
|
|
978
1079
|
!acknowledgementDue.some((entry) => entry.request_id === requestId)) {
|
|
979
|
-
acknowledgementDue.push({
|
|
1080
|
+
acknowledgementDue.push({
|
|
1081
|
+
request_id: requestId,
|
|
1082
|
+
recorded_at: accepted.recorded_at,
|
|
1083
|
+
text_required: answer.agent_acknowledgement_text_required !== false,
|
|
1084
|
+
});
|
|
980
1085
|
}
|
|
981
1086
|
}
|
|
982
1087
|
const next = {
|
|
@@ -1018,10 +1123,69 @@ export function clearAcknowledgementObligation(sessionId, env, requestId) {
|
|
|
1018
1123
|
}
|
|
1019
1124
|
function acknowledgementBlockContext(due) {
|
|
1020
1125
|
const commands = due
|
|
1021
|
-
.map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
|
|
1126
|
+
.map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id, entry.text_required !== false)}\``)
|
|
1022
1127
|
.join('\n');
|
|
1128
|
+
const anyTextRequired = due.some((entry) => entry.text_required !== false);
|
|
1023
1129
|
return (`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1024
|
-
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}
|
|
1130
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'}${acknowledgementDemand(anyTextRequired)}:\n${commands}`);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* How many turns in a row a session may be held waiting for an acknowledgement.
|
|
1134
|
+
*
|
|
1135
|
+
* The gate is the one place hooks deliberately break the fail-open rule, and it
|
|
1136
|
+
* had no bound at all: an agent that could not or would not acknowledge — or a
|
|
1137
|
+
* server that stayed unreachable, since an error here counts as unresolved —
|
|
1138
|
+
* held every turn of that session for ever. Blocking a user's agent
|
|
1139
|
+
* indefinitely does not get them an acknowledgement; it costs them the agent as
|
|
1140
|
+
* well as the acknowledgement.
|
|
1141
|
+
*/
|
|
1142
|
+
const MAX_ACKNOWLEDGEMENT_BLOCKS = 3;
|
|
1143
|
+
/**
|
|
1144
|
+
* Hold the turn for an outstanding acknowledgement, or give up and let it
|
|
1145
|
+
* through once holding has stopped being worth its cost.
|
|
1146
|
+
*
|
|
1147
|
+
* Returns the outcome that blocks the turn, or `null` to carry on.
|
|
1148
|
+
*/
|
|
1149
|
+
function holdForAcknowledgement(ctx, sessionId, due, notes) {
|
|
1150
|
+
if (due.length === 0)
|
|
1151
|
+
return null;
|
|
1152
|
+
const requestIds = due.map((entry) => entry.request_id);
|
|
1153
|
+
const blocks = (readSessionState(sessionId, ctx.env).acknowledgement_blocks ?? 0) + 1;
|
|
1154
|
+
if (blocks > MAX_ACKNOWLEDGEMENT_BLOCKS) {
|
|
1155
|
+
// Drop the obligation with the reason recorded. The answer was already
|
|
1156
|
+
// delivered; what is lost is the agent's receipt for it, and the log is
|
|
1157
|
+
// where that loss stays visible.
|
|
1158
|
+
for (const requestId of requestIds)
|
|
1159
|
+
clearAcknowledgementObligation(sessionId, ctx.env, requestId);
|
|
1160
|
+
resetAcknowledgementBlocks(sessionId, ctx.env);
|
|
1161
|
+
gate(ctx, 'proceeding', 'acknowledgement-abandoned', {
|
|
1162
|
+
request_ids: requestIds,
|
|
1163
|
+
blocks: blocks - 1,
|
|
1164
|
+
limit: MAX_ACKNOWLEDGEMENT_BLOCKS,
|
|
1165
|
+
});
|
|
1166
|
+
notes.push(`no acknowledgement after ${MAX_ACKNOWLEDGEMENT_BLOCKS} turns; continuing without one rather than holding this session for ever`);
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
1170
|
+
...current,
|
|
1171
|
+
acknowledgement_blocks: blocks,
|
|
1172
|
+
}));
|
|
1173
|
+
gate(ctx, 'held', 'acknowledgement-required', { request_ids: requestIds, blocks });
|
|
1174
|
+
return {
|
|
1175
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1176
|
+
notes,
|
|
1177
|
+
log: { stage: 'acknowledgement-required', request_ids: requestIds },
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/** A turn that was not held resets the streak; only consecutive holds count. */
|
|
1181
|
+
function resetAcknowledgementBlocks(sessionId, env) {
|
|
1182
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1183
|
+
if (current.acknowledgement_blocks === undefined)
|
|
1184
|
+
return current;
|
|
1185
|
+
const next = { ...current };
|
|
1186
|
+
delete next.acknowledgement_blocks;
|
|
1187
|
+
return next;
|
|
1188
|
+
});
|
|
1025
1189
|
}
|
|
1026
1190
|
async function reconcileAcknowledgementObligations(ctx, sessionId, due) {
|
|
1027
1191
|
const unresolved = [];
|
|
@@ -1151,6 +1315,8 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1151
1315
|
reply: replies.at(-1),
|
|
1152
1316
|
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
1153
1317
|
entry.agent_acknowledgement_required,
|
|
1318
|
+
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
1319
|
+
entry.agent_acknowledgement_text_required,
|
|
1154
1320
|
};
|
|
1155
1321
|
});
|
|
1156
1322
|
if (answered.length > 0) {
|
|
@@ -1184,6 +1350,16 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1184
1350
|
...(unasked.length > 0 ? { pending: unasked } : {}),
|
|
1185
1351
|
...(retiring.length > 0 ? { retiring } : {}),
|
|
1186
1352
|
...(current.accepted === undefined ? {} : { accepted: current.accepted }),
|
|
1353
|
+
// Carried across a prompt on purpose. This rewrite rebuilds the state
|
|
1354
|
+
// from named fields, so anything omitted is silently erased — and an
|
|
1355
|
+
// erased obligation means the user never learns their reply was read,
|
|
1356
|
+
// which is the one thing acknowledgement exists to guarantee.
|
|
1357
|
+
...(current.acknowledgement_due === undefined
|
|
1358
|
+
? {}
|
|
1359
|
+
: { acknowledgement_due: current.acknowledgement_due }),
|
|
1360
|
+
...(current.acknowledgement_blocks === undefined
|
|
1361
|
+
? {}
|
|
1362
|
+
: { acknowledgement_blocks: current.acknowledgement_blocks }),
|
|
1187
1363
|
// The user typed, so whatever chain of answer-driven continuations was
|
|
1188
1364
|
// running, a human has taken the turn and the consecutive count starts
|
|
1189
1365
|
// over. Only a real prompt reaches here: the journal branch above returns
|
|
@@ -1207,7 +1383,7 @@ export async function handleUserPromptSubmit(ctx, envelope) {
|
|
|
1207
1383
|
notes.push('the late device answer will continue the agent at this turn’s Stop');
|
|
1208
1384
|
return { notes };
|
|
1209
1385
|
}
|
|
1210
|
-
const retired = await drainRetirements(ctx, sessionId, ctx.env
|
|
1386
|
+
const retired = await drainRetirements(ctx, sessionId, ctx.env);
|
|
1211
1387
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
1212
1388
|
const swept = [...retired, ...orphaned];
|
|
1213
1389
|
if (swept.length > 0) {
|
|
@@ -1304,42 +1480,23 @@ export async function runEscalationWaiter(ctx, options) {
|
|
|
1304
1480
|
state = readSessionState(sessionId, ctx.env);
|
|
1305
1481
|
const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
|
|
1306
1482
|
const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
});
|
|
1311
|
-
return {
|
|
1312
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1313
|
-
notes,
|
|
1314
|
-
log: {
|
|
1315
|
-
stage: 'acknowledgement-required',
|
|
1316
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
1317
|
-
},
|
|
1318
|
-
};
|
|
1319
|
-
}
|
|
1483
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes);
|
|
1484
|
+
if (held !== null)
|
|
1485
|
+
return held;
|
|
1320
1486
|
state = readSessionState(sessionId, ctx.env);
|
|
1321
1487
|
}
|
|
1322
1488
|
if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
1323
1489
|
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due);
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
return {
|
|
1329
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1330
|
-
notes,
|
|
1331
|
-
log: {
|
|
1332
|
-
stage: 'acknowledgement-required',
|
|
1333
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
1334
|
-
},
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1490
|
+
const held = holdForAcknowledgement(ctx, sessionId, due, notes);
|
|
1491
|
+
if (held !== null)
|
|
1492
|
+
return held;
|
|
1493
|
+
resetAcknowledgementBlocks(sessionId, ctx.env);
|
|
1337
1494
|
state = readSessionState(sessionId, ctx.env);
|
|
1338
1495
|
}
|
|
1339
1496
|
const pending = pendingList(state);
|
|
1340
1497
|
if (pending.length === 0) {
|
|
1341
1498
|
const swept = [
|
|
1342
|
-
...(await drainRetirements(ctx, sessionId, ctx.env
|
|
1499
|
+
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
1343
1500
|
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
1344
1501
|
];
|
|
1345
1502
|
if (swept.length > 0) {
|
|
@@ -1386,6 +1543,18 @@ export function hookContinuationRoute() {
|
|
|
1386
1543
|
async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
|
|
1387
1544
|
const { answers: answered, remaining } = accepted;
|
|
1388
1545
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
|
|
1546
|
+
const held = accepted.held_deliveries ?? 0;
|
|
1547
|
+
if (held >= MAX_HELD_DELIVERIES) {
|
|
1548
|
+
gate(ctx, 'held', 'delivery-limit', {
|
|
1549
|
+
route: route.kind,
|
|
1550
|
+
held,
|
|
1551
|
+
limit: MAX_HELD_DELIVERIES,
|
|
1552
|
+
request_ids: requestIds,
|
|
1553
|
+
});
|
|
1554
|
+
settleAcceptedAnswers(ctx, sessionId, accepted);
|
|
1555
|
+
notes.push(`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`);
|
|
1556
|
+
return { notes };
|
|
1557
|
+
}
|
|
1389
1558
|
const attempt = (accepted.delivery_attempts ?? 0) + 1;
|
|
1390
1559
|
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
1391
1560
|
gate(ctx, 'held', 'delivery-limit', {
|
|
@@ -1433,6 +1602,18 @@ async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes) {
|
|
|
1433
1602
|
delivered_route: deliveredRoute,
|
|
1434
1603
|
}));
|
|
1435
1604
|
}
|
|
1605
|
+
else if (delivered.acknowledgement === 'held') {
|
|
1606
|
+
// Give the delivery attempt back and count the hold instead. The attempt
|
|
1607
|
+
// was counted before the call because one that dies mid-delivery still
|
|
1608
|
+
// happened and a counter written afterwards could be evaded by crashing —
|
|
1609
|
+
// but a route that *returns* `held` proves both that this process survived
|
|
1610
|
+
// and that nothing was handed over.
|
|
1611
|
+
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1612
|
+
...current,
|
|
1613
|
+
delivery_attempts: accepted.delivery_attempts ?? 0,
|
|
1614
|
+
held_deliveries: (current.held_deliveries ?? 0) + 1,
|
|
1615
|
+
}));
|
|
1616
|
+
}
|
|
1436
1617
|
ctx.log?.info('hook.answer', {
|
|
1437
1618
|
answered: true,
|
|
1438
1619
|
stage: deliveredStage,
|
|
@@ -1461,7 +1642,11 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1461
1642
|
for (const entry of live)
|
|
1462
1643
|
await closeQuietly(ctx, entry.request_id);
|
|
1463
1644
|
await retirePendings(ctx, envelope, sessionId, pending, 'expired');
|
|
1464
|
-
|
|
1645
|
+
// Not `no-question`: there was a question, and this says so. Reading the
|
|
1646
|
+
// log to find out why an ask never travelled is the whole reason these
|
|
1647
|
+
// reasons exist, and the wrong one sent that reader looking for a question
|
|
1648
|
+
// that was right there.
|
|
1649
|
+
gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness });
|
|
1465
1650
|
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
|
|
1466
1651
|
return { notes };
|
|
1467
1652
|
}
|
|
@@ -1489,6 +1674,8 @@ async function handleClaimedStop(ctx, envelope, sessionId, state, pending, notes
|
|
|
1489
1674
|
reply: replies.at(-1),
|
|
1490
1675
|
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
1491
1676
|
entry.agent_acknowledgement_required,
|
|
1677
|
+
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
1678
|
+
entry.agent_acknowledgement_text_required,
|
|
1492
1679
|
};
|
|
1493
1680
|
});
|
|
1494
1681
|
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
@@ -1580,31 +1767,40 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1580
1767
|
// its own notification — one ask never stands in for another.
|
|
1581
1768
|
const submitted = [];
|
|
1582
1769
|
for (const entry of unasked) {
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1770
|
+
// How long the answer is accepted, and how long this owner listens for it,
|
|
1771
|
+
// are two different spans. They used to be one: the window was whatever
|
|
1772
|
+
// remained of the waiter's ceiling, so a question asked at a turn's end
|
|
1773
|
+
// stopped being answerable about eight minutes later, whatever the user
|
|
1774
|
+
// had configured.
|
|
1775
|
+
//
|
|
1776
|
+
// That was right when the waiter was the only way an answer came back. It
|
|
1777
|
+
// is not any more: a late answer is found by the next turn's poll or
|
|
1778
|
+
// replayed from the journal, so this owner giving up listening says
|
|
1779
|
+
// nothing about whether the user's answer is still wanted.
|
|
1780
|
+
const replyWindowSeconds = ctx.config.reply_window_seconds.value;
|
|
1781
|
+
if (ceilingAt - ctx.now() < MIN_REPLY_WINDOW_SECONDS * 1000) {
|
|
1782
|
+
notes.push('too little of the waiter ceiling is left to submit and confirm this question; leaving it in the terminal');
|
|
1589
1783
|
continue;
|
|
1590
1784
|
}
|
|
1785
|
+
// The owner still stops listening at its own ceiling. Only the span the
|
|
1786
|
+
// server accepts answers into changed.
|
|
1591
1787
|
const ownerDeadlineAt = ceilingAt;
|
|
1592
1788
|
const questions = pendingQuestions(entry);
|
|
1593
1789
|
let intent = entry.submission;
|
|
1594
1790
|
if (intent === undefined) {
|
|
1791
|
+
if (entry.body === undefined) {
|
|
1792
|
+
notes.push('registered question has no canonical body; register it again with this CLI');
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1595
1795
|
const prepared = await prepareQuestionSubmission(ctx, {
|
|
1596
|
-
title
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
// A set is answered on the expanded card; the banner leads with the
|
|
1600
|
-
// first question and says how much more is waiting behind it.
|
|
1601
|
-
body: questions.length > 1
|
|
1602
|
-
? `${questions[0].text} (+${questions.length - 1} more)`
|
|
1603
|
-
: entry.question,
|
|
1796
|
+
// Type and Project have their own fields; the title is only substance.
|
|
1797
|
+
title: questions[0].text,
|
|
1798
|
+
body: entry.body,
|
|
1604
1799
|
questions,
|
|
1605
|
-
...(entry.
|
|
1800
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1801
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1802
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
1606
1803
|
event: 'agent_question',
|
|
1607
|
-
session: sessionLabel(ctx, envelope),
|
|
1608
1804
|
windowSeconds: replyWindowSeconds,
|
|
1609
1805
|
ownerDeadlineAt,
|
|
1610
1806
|
});
|
|
@@ -1658,12 +1854,77 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1658
1854
|
receipt = await submitQuestion(ctx, intent);
|
|
1659
1855
|
admissionConfirmed = true;
|
|
1660
1856
|
}
|
|
1661
|
-
catch (
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1857
|
+
catch (caught) {
|
|
1858
|
+
let err = caught;
|
|
1859
|
+
if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
|
|
1860
|
+
ctx.log?.error('hook.pushed', {
|
|
1861
|
+
ok: false,
|
|
1862
|
+
request_id: intent.request_id,
|
|
1863
|
+
status: err.status,
|
|
1864
|
+
code: err.code,
|
|
1865
|
+
message: err.message,
|
|
1866
|
+
});
|
|
1867
|
+
if (entry.submission !== undefined) {
|
|
1868
|
+
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
|
|
1869
|
+
clearFrozenSubmission(sessionId, ctx.env, entry);
|
|
1870
|
+
if (entry.body === undefined) {
|
|
1871
|
+
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1872
|
+
notes.push('registered question has no canonical body; retired the frozen draft instead of retrying it');
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const reminted = await prepareQuestionSubmission(ctx, {
|
|
1876
|
+
title: questions[0].text,
|
|
1877
|
+
body: entry.body,
|
|
1878
|
+
questions,
|
|
1879
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1880
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1881
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
1882
|
+
event: 'agent_question',
|
|
1883
|
+
windowSeconds: replyWindowSeconds,
|
|
1884
|
+
ownerDeadlineAt,
|
|
1885
|
+
});
|
|
1886
|
+
if ('error' in reminted) {
|
|
1887
|
+
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1888
|
+
notes.push(reminted.error);
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
intent = reminted;
|
|
1892
|
+
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1893
|
+
const list = pendingList(current);
|
|
1894
|
+
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
1895
|
+
if (index < 0)
|
|
1896
|
+
return current;
|
|
1897
|
+
const next = [...list];
|
|
1898
|
+
next[index] = { ...next[index], submission: reminted };
|
|
1899
|
+
return { ...current, pending: next };
|
|
1900
|
+
});
|
|
1901
|
+
try {
|
|
1902
|
+
receipt = await submitQuestion(ctx, intent);
|
|
1903
|
+
admissionConfirmed = true;
|
|
1904
|
+
}
|
|
1905
|
+
catch (retryErr) {
|
|
1906
|
+
if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
|
|
1907
|
+
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1908
|
+
notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
|
|
1909
|
+
ctx.log?.error('hook.pushed', {
|
|
1910
|
+
ok: false,
|
|
1911
|
+
request_id: intent.request_id,
|
|
1912
|
+
status: retryErr.status,
|
|
1913
|
+
code: retryErr.code,
|
|
1914
|
+
message: retryErr.message,
|
|
1915
|
+
});
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
err = retryErr;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
else {
|
|
1922
|
+
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1923
|
+
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
1667
1928
|
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
|
|
1668
1929
|
ctx.log?.error('hook.pushed', {
|
|
1669
1930
|
ok: false,
|
|
@@ -1674,11 +1935,10 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1674
1935
|
});
|
|
1675
1936
|
continue;
|
|
1676
1937
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
|
|
1938
|
+
if (!admissionConfirmed) {
|
|
1939
|
+
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
|
|
1940
|
+
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
|
|
1941
|
+
}
|
|
1682
1942
|
}
|
|
1683
1943
|
if (admissionConfirmed) {
|
|
1684
1944
|
ctx.log?.info('hook.pushed', {
|
|
@@ -1697,13 +1957,21 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1697
1957
|
request_id: intent.request_id,
|
|
1698
1958
|
collapse_key: intent.collapse_key,
|
|
1699
1959
|
device_ids: intent.device_ids,
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
|
|
1960
|
+
// When the server stops accepting an answer — not when this owner stops
|
|
1961
|
+
// waiting for one. Those were the same number until the window became a
|
|
1962
|
+
// setting, and taking the minimum silently capped every question at the
|
|
1963
|
+
// waiter's ceiling: a question asked with a day-long window was swept as
|
|
1964
|
+
// stale about eight minutes later and retired, so the answer given over
|
|
1965
|
+
// lunch met a closed question.
|
|
1966
|
+
//
|
|
1967
|
+
// Both consumers of this field already clamp to the owner's own ceiling
|
|
1968
|
+
// (`Math.min(hardDeadlineAt, …)` when deriving the ceiling, and
|
|
1969
|
+
// `Math.min(ceilingAt, …)` when deriving the wait), so recording the
|
|
1970
|
+
// real expiry here lengthens what the user gets without lengthening what
|
|
1971
|
+
// this process waits.
|
|
1972
|
+
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
1705
1973
|
? committedReplyDeadline
|
|
1706
|
-
: intent.owner_deadline_at
|
|
1974
|
+
: intent.owner_deadline_at,
|
|
1707
1975
|
};
|
|
1708
1976
|
delete live.submission;
|
|
1709
1977
|
submitted.push(live);
|
|
@@ -1770,10 +2038,32 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1770
2038
|
if (permanentFailure !== null)
|
|
1771
2039
|
notes.push(permanentFailure);
|
|
1772
2040
|
if (waited.byRequest.size === 0) {
|
|
1773
|
-
//
|
|
1774
|
-
//
|
|
1775
|
-
//
|
|
1776
|
-
|
|
2041
|
+
// This owner is done listening. That used to mean the question was over:
|
|
2042
|
+
// it closed the window server-side and retired the record, on the reasoning
|
|
2043
|
+
// that no later answer could reach the same agent turn.
|
|
2044
|
+
//
|
|
2045
|
+
// The reaching was never the point — the *accepting* is. A question whose
|
|
2046
|
+
// window is still open is polled again by the next prompt and the next
|
|
2047
|
+
// Stop, and an answer found there is journaled and replayed. Closing here
|
|
2048
|
+
// capped every question at this owner's ceiling however long a window the
|
|
2049
|
+
// user had configured, which is what made a day-long window mean eight
|
|
2050
|
+
// minutes.
|
|
2051
|
+
//
|
|
2052
|
+
// So: finalize only what the server has genuinely stopped accepting, and
|
|
2053
|
+
// leave the rest live for the turn that comes next. A session that truly
|
|
2054
|
+
// ends still retires its questions — that is what the session-end hook is
|
|
2055
|
+
// for, and it is the honest place for it.
|
|
2056
|
+
const expired = waitingOn.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
2057
|
+
entry.reply_deadline_at <= ctx.now() ||
|
|
2058
|
+
// A permanent rejection is the server saying this question can never be
|
|
2059
|
+
// answered, whatever its window says. Leaving it open would strand a
|
|
2060
|
+
// dead question on the user's devices.
|
|
2061
|
+
waited.permanentFailures.has(entry.request_id));
|
|
2062
|
+
const stillAnswerable = waitingOn.filter((entry) => !expired.includes(entry));
|
|
2063
|
+
if (stillAnswerable.length > 0) {
|
|
2064
|
+
notes.push(`${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`);
|
|
2065
|
+
}
|
|
2066
|
+
const finalized = await finalizePendings(ctx, expired);
|
|
1777
2067
|
const finalAnswers = finalized
|
|
1778
2068
|
.map(finalizedAnswer)
|
|
1779
2069
|
.filter((entry) => entry !== null);
|
|
@@ -1828,6 +2118,7 @@ async function escalate(ctx, envelope, sessionId, unasked, alreadyLive, notes, h
|
|
|
1828
2118
|
reply: authoritative.at(-1),
|
|
1829
2119
|
replies: authoritative,
|
|
1830
2120
|
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
2121
|
+
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
1831
2122
|
});
|
|
1832
2123
|
if (finalized.response === null) {
|
|
1833
2124
|
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
@@ -1875,8 +2166,7 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1875
2166
|
}
|
|
1876
2167
|
}
|
|
1877
2168
|
if (orphans.length > 0) {
|
|
1878
|
-
|
|
1879
|
-
orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now);
|
|
2169
|
+
orphanRetirements(env, orphans, now);
|
|
1880
2170
|
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1881
2171
|
}
|
|
1882
2172
|
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
@@ -1913,12 +2203,21 @@ export function handleSessionEnd(env, envelope, now = Date.now()) {
|
|
|
1913
2203
|
// ask — register a question for the turn's end
|
|
1914
2204
|
// ---------------------------------------------------------------------------
|
|
1915
2205
|
/**
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
2206
|
+
* Registrations still waiting for their first push. More than this in one turn
|
|
2207
|
+
* means an agent is looping, not asking, and related questions belong in one
|
|
2208
|
+
* `ask --form` instead.
|
|
2209
|
+
*
|
|
2210
|
+
* Deliberately counts only unasked entries. A question already on the user's
|
|
2211
|
+
* devices is waiting on a person, not on the agent, and an answer stays
|
|
2212
|
+
* accepted for a day — so counting live questions here would have turned a
|
|
2213
|
+
* patient user into a reason the agent could no longer ask anything.
|
|
1920
2214
|
*/
|
|
1921
2215
|
export const MAX_PENDING_QUESTIONS = 4;
|
|
2216
|
+
/**
|
|
2217
|
+
* Everything this session has open at once, asked or not. The lock screen is
|
|
2218
|
+
* the shared resource being protected here, not the agent's turn.
|
|
2219
|
+
*/
|
|
2220
|
+
export const MAX_LIVE_QUESTIONS = 10;
|
|
1922
2221
|
/**
|
|
1923
2222
|
* A session may hold several registered questions at once: a new `ask` never
|
|
1924
2223
|
* ends an earlier one. Each reaches the user as its own notification and is
|
|
@@ -1932,11 +2231,16 @@ export const MAX_PENDING_QUESTIONS = 4;
|
|
|
1932
2231
|
* running in one project at once, and their questions must never interfere.
|
|
1933
2232
|
*/
|
|
1934
2233
|
export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
1935
|
-
let full =
|
|
2234
|
+
let full = null;
|
|
1936
2235
|
updateSessionState(sessionId, env, (state) => {
|
|
1937
2236
|
const pending = pendingList(state);
|
|
1938
|
-
|
|
1939
|
-
|
|
2237
|
+
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
2238
|
+
if (unasked.length >= MAX_PENDING_QUESTIONS) {
|
|
2239
|
+
full = 'unasked';
|
|
2240
|
+
return state;
|
|
2241
|
+
}
|
|
2242
|
+
if (pending.length >= MAX_LIVE_QUESTIONS) {
|
|
2243
|
+
full = 'live';
|
|
1940
2244
|
return state;
|
|
1941
2245
|
}
|
|
1942
2246
|
return {
|
|
@@ -1952,10 +2256,14 @@ export function registerQuestion(sessionId, env, question, now = Date.now()) {
|
|
|
1952
2256
|
],
|
|
1953
2257
|
};
|
|
1954
2258
|
});
|
|
1955
|
-
if (full) {
|
|
2259
|
+
if (full === 'unasked') {
|
|
1956
2260
|
throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
1957
2261
|
'Combine related questions into one `notifai ask --form` instead of registering more.');
|
|
1958
2262
|
}
|
|
2263
|
+
if (full === 'live') {
|
|
2264
|
+
throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
2265
|
+
'Retire the ones you no longer need with `notifai close <request_id>` before asking another.');
|
|
2266
|
+
}
|
|
1959
2267
|
}
|
|
1960
2268
|
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|
|
1961
2269
|
export function parseHookInput(raw) {
|