approval-md 0.4.5 → 0.4.6
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/SPEC.md +13 -2
- package/dist/src/channels/telegram.d.ts +105 -31
- package/dist/src/channels/telegram.js +157 -81
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +100 -35
- package/dist/src/cli/channel-telegram.js +216 -119
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/doctor.js +33 -13
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/hook-exec-code.d.ts +150 -0
- package/dist/src/cli/hook-exec-code.js +357 -0
- package/dist/src/cli/hook-exec-code.js.map +1 -0
- package/dist/src/cli/hook.d.ts +125 -1
- package/dist/src/cli/hook.js +2177 -107
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +349 -1
- package/dist/src/core/command-class.js +2347 -87
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/exec-code.d.ts +77 -0
- package/dist/src/core/exec-code.js +60 -0
- package/dist/src/core/exec-code.js.map +1 -0
- package/dist/src/core/git-push-argv.d.ts +111 -0
- package/dist/src/core/git-push-argv.js +375 -0
- package/dist/src/core/git-push-argv.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +71 -30
- package/dist/src/core/harness-version.js +101 -34
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +8 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/docs/README-extended.md +3 -2
- package/docs/cli-reference.md +20 -7
- package/package.json +2 -2
- package/schema/event.schema.json +1 -1
- package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
- package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
- package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
- package/schema/policy.schema.json +23 -0
|
@@ -115,7 +115,7 @@ import { assembleBatch } from "../channels/batch.js";
|
|
|
115
115
|
import { recordChannelDecision, recordSurfaceRefusal, refusedDecisionLine, } from "../channels/contract.js";
|
|
116
116
|
import { ageText, attestedPromptOf, buildPendingQueue, } from "../channels/tagging.js";
|
|
117
117
|
import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
|
|
118
|
-
import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
118
|
+
import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_CLAIMED_LABEL, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
119
119
|
import { openReviewCards } from "./audit-card.js";
|
|
120
120
|
import { reviewSample } from "../core/audit.js";
|
|
121
121
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
@@ -853,7 +853,8 @@ export const DISPATCH_LOUD_ATTEMPTS = 3;
|
|
|
853
853
|
*/
|
|
854
854
|
export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
855
855
|
/**
|
|
856
|
-
* The
|
|
856
|
+
* The one message that introduces the first batch a listener process sends
|
|
857
|
+
* (APRV-196). Two lines, sent together by `announce`, the first in bold.
|
|
857
858
|
*
|
|
858
859
|
* **Why a banner and not an edit of the earlier copies.** The incident was a
|
|
859
860
|
* restart re-sending five pending requests with no warning, on top of five
|
|
@@ -869,27 +870,59 @@ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
|
869
870
|
* (`actionRefOf` in `channels/telegram.ts`), so a human who taps the copy they
|
|
870
871
|
* can see decides the request they meant.
|
|
871
872
|
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
873
|
+
* **The words are a resident's, not the system's.** The reader is whoever
|
|
874
|
+
* holds the phone, so the banner is one short message in plain words: how many
|
|
875
|
+
* requests are still waiting, and whether the buttons on the older messages
|
|
876
|
+
* above still work. It names no process, no log and no cycle, and it says
|
|
877
|
+
* neither "started" nor "restarted": a listener cannot tell the two apart,
|
|
878
|
+
* having deliberately kept nothing that would let it, so the text claims only
|
|
879
|
+
* what is true either way (these requests are still waiting). The old third
|
|
880
|
+
* line, about where the pending set is read from, is gone; it described the
|
|
881
|
+
* runtime to someone who only needs to know which button to press.
|
|
876
882
|
*
|
|
877
883
|
* **With the stale-copy fallback off (APRV-456)** the second line says the
|
|
878
|
-
* opposite, because the earlier copies' buttons no longer
|
|
879
|
-
*
|
|
880
|
-
*
|
|
884
|
+
* opposite, because the earlier copies' buttons no longer work: a relayed
|
|
885
|
+
* listener refuses a nonce it is not holding (`nonce-not-issued`), so the
|
|
886
|
+
* banner tells the approver to use the buttons below it. A banner that kept
|
|
881
887
|
* promising working older copies would be this channel's own text sending the
|
|
882
888
|
* approver to a button that refuses them.
|
|
883
889
|
*/
|
|
884
890
|
export function bannerLines(pending, staleCopy = true) {
|
|
885
|
-
const
|
|
886
|
-
const live = `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}.`;
|
|
891
|
+
const one = pending === 1;
|
|
887
892
|
return [
|
|
888
|
-
`
|
|
893
|
+
`Reminder: ${pending} ${one ? "request is" : "requests are"} still waiting for you.`,
|
|
889
894
|
staleCopy
|
|
890
|
-
? `${
|
|
891
|
-
:
|
|
892
|
-
|
|
895
|
+
? `${one ? "It is" : "They are"} repeated below; buttons on older messages above still work, and nothing is answered twice.`
|
|
896
|
+
: "Use the buttons below; buttons on older messages above no longer work.",
|
|
897
|
+
];
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* The reminder a collapsed re-delivery opens with, in place of the banner
|
|
901
|
+
* (P-9).
|
|
902
|
+
*
|
|
903
|
+
* Before this, a restart with two stale requests and one live one sent the
|
|
904
|
+
* stale summary first and the {@link bannerLines} banner second, and the banner
|
|
905
|
+
* counted only the cards after it: "1 request is still waiting" with three
|
|
906
|
+
* pending and the summary of the other two directly above. Two reminders, the
|
|
907
|
+
* more verbose one first, and a count that read as wrong. Now the summary IS the
|
|
908
|
+
* reminder: its first line counts everything this cycle re-sends (`pending`,
|
|
909
|
+
* collapsed and carded together), and the banner is skipped.
|
|
910
|
+
*
|
|
911
|
+
* `following` is how many requests get their own card after the summary. When
|
|
912
|
+
* there are any, the second line says so and says whether the buttons on older
|
|
913
|
+
* messages still work, which is what the banner it replaces said (APRV-456).
|
|
914
|
+
* When there are none, the summary is the whole restart and nothing follows.
|
|
915
|
+
*/
|
|
916
|
+
export function staleReminderLines(pending, following, staleCopy = true) {
|
|
917
|
+
const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} still waiting for you.`;
|
|
918
|
+
if (following <= 0)
|
|
919
|
+
return [head];
|
|
920
|
+
const below = following === 1 ? "The other one is repeated below" : `The other ${String(following)} are repeated below`;
|
|
921
|
+
return [
|
|
922
|
+
head,
|
|
923
|
+
staleCopy
|
|
924
|
+
? `${below}; buttons on older messages above still work.`
|
|
925
|
+
: `${below}; use ${following === 1 ? "its buttons" : "their buttons"}, as buttons on older messages above no longer work.`,
|
|
893
926
|
];
|
|
894
927
|
}
|
|
895
928
|
/**
|
|
@@ -918,75 +951,125 @@ export function staleCopyFor(apiBase, noStaleCopy) {
|
|
|
918
951
|
// Paced delivery (APRV-216) — one question at a time
|
|
919
952
|
// ---------------------------------------------------------------------------
|
|
920
953
|
/**
|
|
921
|
-
* The
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
954
|
+
* The count line a paced cycle sends when the pending set has grown, and the
|
|
955
|
+
* header of `/queue` (APRV-216), in a resident's words (P-9).
|
|
956
|
+
*
|
|
957
|
+
* One line, and everything in it is arithmetic on the verified log at the
|
|
958
|
+
* instant it is written: how many requests are waiting and how long the oldest
|
|
959
|
+
* has waited. Nothing is remembered between calls, so two summaries a minute
|
|
960
|
+
* apart can disagree only because the log moved.
|
|
961
|
+
*
|
|
962
|
+
* Before P-9 it also carried a class tally ("communicate.email.external ×3").
|
|
963
|
+
* The tally told an operator what kind of work was queued, and told a resident
|
|
964
|
+
* nothing they could read; the kind of each request is on its own card, and
|
|
965
|
+
* `/queue` now lists each by the agent's own summary ({@link queueLines}).
|
|
966
|
+
*
|
|
967
|
+
* The oldest is the one asked longest ago, whatever order `requests` is in:
|
|
968
|
+
* `/queue` passes the set in presentation order ({@link orderPending}, live
|
|
969
|
+
* newest first), where the first entry is the NEWEST, and a header that read
|
|
970
|
+
* the first entry's age called the newest one the oldest.
|
|
932
971
|
*/
|
|
933
972
|
export function summaryLines(requests, now) {
|
|
934
973
|
if (requests.length === 0)
|
|
935
|
-
return ["Nothing
|
|
936
|
-
const
|
|
937
|
-
const oldest = requests[0];
|
|
938
|
-
const oldestMs = Date.parse(oldest.requested_ts.value);
|
|
939
|
-
const age = Number.isNaN(nowMs) || Number.isNaN(oldestMs) ? "unknown age" : ageText(nowMs - oldestMs);
|
|
940
|
-
const tally = new Map();
|
|
941
|
-
for (const request of requests) {
|
|
942
|
-
const cls = request.class.value;
|
|
943
|
-
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
944
|
-
}
|
|
945
|
-
const classes = [...tally.entries()]
|
|
946
|
-
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
947
|
-
.join(", ");
|
|
974
|
+
return ["Nothing is waiting for you right now."];
|
|
975
|
+
const one = requests.length === 1;
|
|
948
976
|
return [
|
|
949
|
-
`${String(requests.length)}
|
|
977
|
+
`${String(requests.length)} ${one ? "request is" : "requests are"} waiting for you (${one ? "asked" : "oldest"} ${oldestAge(requests, now)}).`,
|
|
950
978
|
];
|
|
951
979
|
}
|
|
980
|
+
/** How long ago the longest-waiting request was asked, in words. */
|
|
981
|
+
function oldestAge(requests, now) {
|
|
982
|
+
const nowMs = Date.parse(now);
|
|
983
|
+
const instants = requests
|
|
984
|
+
.map((request) => Date.parse(request.requested_ts.value))
|
|
985
|
+
.filter((at) => !Number.isNaN(at));
|
|
986
|
+
if (Number.isNaN(nowMs) || instants.length === 0)
|
|
987
|
+
return "at an unknown time";
|
|
988
|
+
return ageText(nowMs - Math.min(...instants));
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* The reminder a paced listener's first cycle opens with (P-9).
|
|
992
|
+
*
|
|
993
|
+
* Paced is the product default (APRV-216), and a restart there used to open
|
|
994
|
+
* with the bare count line and then one card. Nothing in it said this was a
|
|
995
|
+
* reminder, or whether the buttons on older messages above still worked, which
|
|
996
|
+
* matters most behind a relay, where they do not (APRV-456). So the first
|
|
997
|
+
* cycle's line is a reminder in the burst banner's words ({@link bannerLines}):
|
|
998
|
+
* how many are waiting, how many are below, and what the older buttons do.
|
|
999
|
+
*
|
|
1000
|
+
* `shown` is how many requests this cycle puts below it: one, or a digest
|
|
1001
|
+
* group. With the stale-copy fallback on, older buttons decide only while their
|
|
1002
|
+
* request is the one being shown, so when fewer are shown than are pending the
|
|
1003
|
+
* line says only the ones below work right now (fold round 1, S1). Later cycles that find the set has grown send {@link summaryLines}
|
|
1004
|
+
* instead, which is a count and not a reminder.
|
|
1005
|
+
*/
|
|
1006
|
+
export function pacedReminderLines(pending, shown, staleCopy = true) {
|
|
1007
|
+
const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} waiting for you.`;
|
|
1008
|
+
const below = shown >= pending
|
|
1009
|
+
? pending === 1
|
|
1010
|
+
? "It is below."
|
|
1011
|
+
: "They are below."
|
|
1012
|
+
: shown === 1
|
|
1013
|
+
? "One is below; the next comes after you answer it."
|
|
1014
|
+
: `${String(shown)} are below; the next come after you answer them.`;
|
|
1015
|
+
if (staleCopy && shown < pending) {
|
|
1016
|
+
// Fold round 1, S1: under paced only the unit below has a live delivery.
|
|
1017
|
+
// An older copy of any OTHER pending request is answered "Still pending —
|
|
1018
|
+
// this copy's buttons are not live here", so "older buttons still work"
|
|
1019
|
+
// would be this channel's own text sending the approver to a refusal.
|
|
1020
|
+
return [
|
|
1021
|
+
head,
|
|
1022
|
+
shown === 1
|
|
1023
|
+
? "One is below. Only its buttons work right now; the others come next, with new buttons, after you answer it."
|
|
1024
|
+
: `${String(shown)} are below. Only their buttons work right now; the others come next, with new buttons, after you answer them.`,
|
|
1025
|
+
];
|
|
1026
|
+
}
|
|
1027
|
+
const older = staleCopy
|
|
1028
|
+
? "Buttons on older messages above still work."
|
|
1029
|
+
: `Buttons on older messages above no longer work; use ${shown === 1 ? "the one" : "the ones"} below.`;
|
|
1030
|
+
return [head, `${below} ${older}`];
|
|
1031
|
+
}
|
|
952
1032
|
/**
|
|
953
|
-
* The marker `/queue` puts on the request this listener has selected (APRV-256)
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* messages, or lost with the chat
|
|
961
|
-
*
|
|
962
|
-
*
|
|
1033
|
+
* The marker `/queue` puts on the request this listener has selected (APRV-256),
|
|
1034
|
+
* in a resident's words (P-9).
|
|
1035
|
+
*
|
|
1036
|
+
* It says one thing and claims no second: a message for this request was sent
|
|
1037
|
+
* earlier, which is the delivery bookkeeping recording that a send once
|
|
1038
|
+
* succeeded. That is not evidence the message is still in the chat: Telegram
|
|
1039
|
+
* reports a successful send, never a message's continued existence, and a card
|
|
1040
|
+
* can be deleted, buried under a thousand later messages, or lost with the chat
|
|
1041
|
+
* history on a reinstall. The marker before APRV-256, "shown now", asserted
|
|
1042
|
+
* present visibility from a past delivery, which is the bug this constant
|
|
1043
|
+
* exists to keep fixed, and why it does not say "on screen" either.
|
|
963
1044
|
*/
|
|
964
|
-
const SELECTED_MARKER = "
|
|
1045
|
+
const SELECTED_MARKER = " · sent to you earlier";
|
|
965
1046
|
/**
|
|
966
1047
|
* What `/queue` says about itself, immediately under the summary (APRV-256).
|
|
967
1048
|
*
|
|
968
1049
|
* `/queue` is a summary reply and carries no buttons, so an approver reading it
|
|
969
1050
|
* on a phone must not be left hunting this message for controls that were never
|
|
970
|
-
* on it. Where the controls DO live is stated without a direction: a
|
|
1051
|
+
* on it. Where the controls DO live is stated without a direction: a message is
|
|
971
1052
|
* somewhere in the chat's history, and "above" was only ever true for the
|
|
972
1053
|
* approver who asked while looking straight at it.
|
|
973
1054
|
*/
|
|
974
|
-
const QUEUE_IS_A_LIST = "This
|
|
1055
|
+
const QUEUE_IS_A_LIST = "This list has no buttons. Each request is answered on its own message, wherever it is in this chat.";
|
|
975
1056
|
/**
|
|
976
1057
|
* `/queue`'s reply: the summary, then one numbered line per pending request.
|
|
977
1058
|
*
|
|
978
1059
|
* Derived, like the summary, from the verified log at reply time and not from
|
|
979
1060
|
* anything this process is holding: the numbering is positional and names no
|
|
980
1061
|
* button, so a stale copy of this list cannot be used to decide anything. The
|
|
981
|
-
* marker says which one
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1062
|
+
* marker says which one was sent earlier, because the question `/queue` is
|
|
1063
|
+
* usually asked to answer is "what else is there besides the one I am looking
|
|
1064
|
+
* at" and, since APRV-256, its unhappy twin, "where is the one I am supposed to
|
|
1065
|
+
* be looking at".
|
|
1066
|
+
*
|
|
1067
|
+
* P-9: each line is the agent's own summary and its age, under the label that
|
|
1068
|
+
* says the summary is the agent's and unchecked. Before P-9 each line was the
|
|
1069
|
+
* action key, the task id, the class and the age, and in real use the first
|
|
1070
|
+
* two are `hook:<session uuid>:<tool_use_id>…`, two long raw ids per line. The
|
|
1071
|
+
* three recovery paragraphs are two short lines: what /skip and /next do, and
|
|
1072
|
+
* that neither answers anything.
|
|
990
1073
|
*/
|
|
991
1074
|
export function queueLines(requests, now, shown) {
|
|
992
1075
|
const lines = summaryLines(requests, now);
|
|
@@ -995,36 +1078,35 @@ export function queueLines(requests, now, shown) {
|
|
|
995
1078
|
const nowMs = Date.parse(now);
|
|
996
1079
|
const current = new Set(shown);
|
|
997
1080
|
let selected = 0;
|
|
998
|
-
lines.push(QUEUE_IS_A_LIST);
|
|
1081
|
+
lines.push(QUEUE_IS_A_LIST, `${TELEGRAM_CLAIMED_LABEL}:`);
|
|
999
1082
|
requests.forEach((request, index) => {
|
|
1000
|
-
const key = request.action_key.value;
|
|
1001
1083
|
const requestedMs = Date.parse(request.requested_ts.value);
|
|
1002
1084
|
const age = Number.isNaN(nowMs) || Number.isNaN(requestedMs)
|
|
1003
1085
|
? "unknown age"
|
|
1004
1086
|
: ageText(nowMs - requestedMs);
|
|
1005
|
-
const
|
|
1006
|
-
const isSelected = current.has(key);
|
|
1087
|
+
const isSelected = current.has(request.action_key.value);
|
|
1007
1088
|
if (isSelected)
|
|
1008
1089
|
selected += 1;
|
|
1009
|
-
lines.push(`${String(index + 1)}. ${
|
|
1090
|
+
lines.push(`${String(index + 1)}. ${request.summary.value ?? "(none given)"} · ${age}${isSelected ? SELECTED_MARKER : ""}`);
|
|
1010
1091
|
});
|
|
1011
1092
|
if (selected === 0) {
|
|
1012
1093
|
// Nothing selected is an ordinary state, not a fault: a decided or passed
|
|
1013
1094
|
// over request leaves the listener holding nothing until the next cycle
|
|
1014
1095
|
// picks the next one up. Saying so is what stops the reader searching the
|
|
1015
|
-
// chat for a
|
|
1016
|
-
lines.push("
|
|
1096
|
+
// chat for a message this process never claimed to have sent.
|
|
1097
|
+
lines.push("None of these has been sent to you yet. The next one arrives with its buttons shortly.");
|
|
1017
1098
|
return lines;
|
|
1018
1099
|
}
|
|
1019
1100
|
// More than one key is marked when the selection is a digest group, which
|
|
1020
|
-
// Telegram receives as ONE
|
|
1021
|
-
//
|
|
1101
|
+
// Telegram receives as ONE summary message covering the set, so the plural
|
|
1102
|
+
// branch speaks of one message. No positional word in either: the reply may
|
|
1022
1103
|
// be chunked across several messages, so "above" is not this function's to
|
|
1023
1104
|
// promise even about its own lines.
|
|
1024
|
-
|
|
1025
|
-
? "
|
|
1026
|
-
:
|
|
1027
|
-
|
|
1105
|
+
lines.push(selected === 1
|
|
1106
|
+
? "Can't find its message? /skip sends the next one instead; this one comes back later."
|
|
1107
|
+
: "Can't find their message? /skip sends the next ones instead; these come back later.", selected === 1
|
|
1108
|
+
? "/next stops showing you this one and does not send it again. Neither command answers anything."
|
|
1109
|
+
: "/next stops showing you these and does not send them again. Neither command answers anything.");
|
|
1028
1110
|
return lines;
|
|
1029
1111
|
}
|
|
1030
1112
|
/**
|
|
@@ -1443,10 +1525,16 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1443
1525
|
if (setup.delivery === "paced" && undelivered.length > 0) {
|
|
1444
1526
|
const pending = queue.requests.length;
|
|
1445
1527
|
if (!state.paced.summarySent || pending > state.paced.announced) {
|
|
1528
|
+
const opening = !state.paced.summarySent;
|
|
1446
1529
|
state.paced.summarySent = true;
|
|
1447
1530
|
state.paced.announced = pending;
|
|
1531
|
+
// P-9: the first one is a reminder that says what the older buttons do;
|
|
1532
|
+
// a later one, for a set that grew, is the count line.
|
|
1533
|
+
const lines = opening
|
|
1534
|
+
? pacedReminderLines(pending, undelivered.length, setup.channel.staleCopyEnabled())
|
|
1535
|
+
: summaryLines(queue.requests, now);
|
|
1448
1536
|
try {
|
|
1449
|
-
const deliveryId = await setup.channel.announce(
|
|
1537
|
+
const deliveryId = await setup.channel.announce(lines);
|
|
1450
1538
|
result.summary = { delivery_id: deliveryId, pending };
|
|
1451
1539
|
}
|
|
1452
1540
|
catch (cause) {
|
|
@@ -1465,7 +1553,15 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1465
1553
|
// started against an empty queue has no re-delivery to announce, ever. See
|
|
1466
1554
|
// {@link bannerLines} for why this is a banner rather than an edit of the
|
|
1467
1555
|
// copies that came before.
|
|
1468
|
-
|
|
1556
|
+
//
|
|
1557
|
+
// P-9: a cycle whose stale summary went out has had its reminder already. The
|
|
1558
|
+
// summary opens with the count of everything this cycle re-sends and says
|
|
1559
|
+
// whether older buttons still work ({@link staleReminderLines}), so a banner
|
|
1560
|
+
// here would be the second reminder of one restart.
|
|
1561
|
+
const announcing = setup.delivery === "burst" &&
|
|
1562
|
+
!state.banner.sent &&
|
|
1563
|
+
undelivered.length > 0 &&
|
|
1564
|
+
result.collapsed === undefined;
|
|
1469
1565
|
state.banner.sent = true;
|
|
1470
1566
|
if (announcing) {
|
|
1471
1567
|
try {
|
|
@@ -1860,55 +1956,51 @@ export function orderPending(requests, now) {
|
|
|
1860
1956
|
return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
|
|
1861
1957
|
}
|
|
1862
1958
|
/**
|
|
1863
|
-
*
|
|
1959
|
+
* The computed lines a collapsed re-delivery leads with (APRV-287), in a
|
|
1960
|
+
* resident's words (P-9).
|
|
1864
1961
|
*
|
|
1865
|
-
* `
|
|
1866
|
-
*
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1962
|
+
* `superseded` is the subset a newer pending request replaced (APRV-425), and
|
|
1963
|
+
* the line states the split rather than claiming every member is old. Before
|
|
1964
|
+
* APRV-425 it said every member was past the hook's wait, which stopped being
|
|
1965
|
+
* true the moment the collapse widened: a message that overstates what it
|
|
1966
|
+
* collapsed is the stale-documentation failure in message form, and this one is
|
|
1967
|
+
* read by the person deciding.
|
|
1968
|
+
*
|
|
1969
|
+
* Before P-9 these lines also spelled out the hook's wait and retry grace in
|
|
1970
|
+
* seconds and minutes, a tally of class names, and a sentence about log events
|
|
1971
|
+
* and identical retries. The reader is whoever holds the phone, so what is left
|
|
1972
|
+
* is the one thing they need: how many are listed, and why nobody needs an
|
|
1973
|
+
* answer to them now. The age of each one moves onto its own line
|
|
1974
|
+
* ({@link staleAges}).
|
|
1869
1975
|
*/
|
|
1870
|
-
function
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1976
|
+
export function staleLines(requests, _now, superseded = new Set()) {
|
|
1977
|
+
const count = requests.length;
|
|
1978
|
+
const listed = `${String(count)} older ${count === 1 ? "one is" : "ones are"} listed here`;
|
|
1979
|
+
const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
|
|
1980
|
+
const abandoned = count - replaced;
|
|
1981
|
+
if (replaced === 0) {
|
|
1982
|
+
return [`${listed}; nobody is waiting on ${count === 1 ? "it" : "them"} any more.`];
|
|
1983
|
+
}
|
|
1984
|
+
if (abandoned === 0) {
|
|
1985
|
+
return [`${listed}; your agent has asked the same thing again since, so answer the newer one instead.`];
|
|
1986
|
+
}
|
|
1987
|
+
return [
|
|
1988
|
+
`${listed}. Nobody is waiting on ${String(abandoned)} of them, and your agent has asked the same thing again since for the other ${String(replaced)}.`,
|
|
1989
|
+
];
|
|
1875
1990
|
}
|
|
1876
1991
|
/**
|
|
1877
|
-
*
|
|
1992
|
+
* How long ago each collapsed request was asked, in the order given (P-9).
|
|
1878
1993
|
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
1882
|
-
* grace", which stopped being true the moment the collapse widened: a message
|
|
1883
|
-
* that overstates what it collapsed is the stale-documentation failure in
|
|
1884
|
-
* message form, and this one is read by the person deciding.
|
|
1994
|
+
* Computed from the verified log's own `requested_ts`, like the rest of the
|
|
1995
|
+
* summary, and rendered beside each member's claimed summary line so the
|
|
1996
|
+
* resident can tell the requests apart without an action key.
|
|
1885
1997
|
*/
|
|
1886
|
-
export function
|
|
1998
|
+
export function staleAges(requests, now) {
|
|
1887
1999
|
const nowMs = Date.parse(now);
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
.
|
|
1891
|
-
|
|
1892
|
-
const tally = new Map();
|
|
1893
|
-
for (const request of requests) {
|
|
1894
|
-
const cls = request.class.value;
|
|
1895
|
-
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
1896
|
-
}
|
|
1897
|
-
const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
|
|
1898
|
-
const abandoned = requests.length - replaced;
|
|
1899
|
-
const why = replaced === 0
|
|
1900
|
-
? `all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`
|
|
1901
|
-
: abandoned === 0
|
|
1902
|
-
? "all superseded by a newer pending request for the same bytes and class"
|
|
1903
|
-
: `${String(abandoned)} older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace, ${String(replaced)} superseded by a newer pending request for the same bytes and class`;
|
|
1904
|
-
return [
|
|
1905
|
-
`${String(requests.length)} pending requests, ${why}`,
|
|
1906
|
-
`oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
|
|
1907
|
-
`classes: ${[...tally.entries()]
|
|
1908
|
-
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
1909
|
-
.join(", ")}`,
|
|
1910
|
-
"collapsed into this one message because nothing is waiting on them; each one stays pending in the log, and a decision on any of them can still authorize an identical retry",
|
|
1911
|
-
];
|
|
2000
|
+
return requests.map((request) => {
|
|
2001
|
+
const at = Date.parse(request.requested_ts.value);
|
|
2002
|
+
return Number.isNaN(nowMs) || Number.isNaN(at) ? "unknown age" : ageText(nowMs - at);
|
|
2003
|
+
});
|
|
1912
2004
|
}
|
|
1913
2005
|
/**
|
|
1914
2006
|
* Put the requests nobody is waiting on into ONE message, and hand back the
|
|
@@ -1980,8 +2072,13 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
|
1980
2072
|
return undecided;
|
|
1981
2073
|
let delivered = null;
|
|
1982
2074
|
try {
|
|
2075
|
+
// P-9: the summary carries this cycle's reminder, counting everything it
|
|
2076
|
+
// re-sends (the collapsed members plus the cards that follow), and the
|
|
2077
|
+
// banner is then skipped. `undecided` is exactly that set on a first cycle.
|
|
1983
2078
|
delivered = await setup.channel.notifyStale(stale, {
|
|
2079
|
+
reminder: staleReminderLines(undecided.length, undecided.length - stale.length, setup.channel.staleCopyEnabled()),
|
|
1984
2080
|
lines: staleLines(stale, now, superseded),
|
|
2081
|
+
ages: staleAges(stale, now),
|
|
1985
2082
|
});
|
|
1986
2083
|
}
|
|
1987
2084
|
catch (cause) {
|