@bli-cockpit/cli 0.2.96 → 0.2.98
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/dist/commands/local-args-collector-status.js +27 -6
- package/dist/commands/local-args-tower-memory.js +151 -0
- package/dist/commands/local-command-shapes-memory.js +22 -0
- package/dist/commands/local-help-commands.js +21 -4
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +8 -0
- package/dist/commands/memory-store.js +331 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-failures.js +14 -0
- package/dist/commands/session-sync-health.js +9 -0
- package/dist/commands/session-sync-hold.js +90 -0
- package/dist/commands/session-sync.js +5 -0
- package/dist/commands/sync-receipt.js +20 -5
- package/dist/commands/sync-report.js +3 -0
- package/dist/delivery-hold-notice.js +166 -0
- package/dist/raw-evidence-staging.js +4 -27
- package/dist/raw-evidence-stuck-summary.js +60 -0
- package/dist/upload-evidence-delivery-summary.js +9 -0
- package/package.json +2 -2
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
|
|
12
12
|
import { assertNoPositionals, normalizeUrl, optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
|
|
13
13
|
import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
|
|
14
|
+
import { MEMORY_STORE_ACTIONS, parseMemoryStoreArgs, } from "./local-args-tower-memory.js";
|
|
14
15
|
export function parseStatusArgs(args) {
|
|
15
16
|
const values = parseNamedArgs(args, {
|
|
16
17
|
allowedFlags: [
|
|
@@ -164,11 +165,30 @@ export function parseAgentRulesArgs(args) {
|
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
167
|
/**
|
|
167
|
-
* BLI-3580
|
|
168
|
-
*
|
|
169
|
-
* `cockpit memory`
|
|
168
|
+
* BLI-3580, amended by BLI-4047.
|
|
169
|
+
*
|
|
170
|
+
* `cockpit memory` is now SIX verbs behind one noun: the two that configure
|
|
171
|
+
* this machine (`install`, `status`), the experience log (`log`), and the four
|
|
172
|
+
* that read and write the memory store itself (`search`, `save`, `update`,
|
|
173
|
+
* `forget`, parsed next door in `local-args-tower-memory.ts`).
|
|
174
|
+
*
|
|
175
|
+
* **A bare `cockpit memory` prints the verb list; it no longer installs.**
|
|
176
|
+
* BLI-3580 made `install` the default because the command is run unasked by
|
|
177
|
+
* `do-everything` and by the sync tick — but those callers build
|
|
178
|
+
* `{ kind: "memory", action: "install" }` in code (`doctor-registration.ts`,
|
|
179
|
+
* `sync-followups-memory.ts`, `onboard-completion.ts`) and never pass through
|
|
180
|
+
* this parser. The only caller the default ever served was a PERSON typing the
|
|
181
|
+
* noun to find out what it does, and what they got was an MCP server and three
|
|
182
|
+
* hooks registered on their machine. Installing stays one word away.
|
|
170
183
|
*/
|
|
171
184
|
export function parseMemoryArgs(args) {
|
|
185
|
+
// The verb word first, always: reading it off `args[0]` rather than "the
|
|
186
|
+
// first token without a leading dash" is what keeps `cockpit memory --home
|
|
187
|
+
// /tmp/x status` from being read as the verb `/tmp/x`.
|
|
188
|
+
const first = args[0];
|
|
189
|
+
if (first !== undefined && MEMORY_STORE_ACTIONS.has(first)) {
|
|
190
|
+
return parseMemoryStoreArgs(first, args);
|
|
191
|
+
}
|
|
172
192
|
const values = parseNamedArgs(args, {
|
|
173
193
|
allowedFlags: ["--home", "--dashboard-url", "--dry-run", "--json", "--store", "--reason-stdin"],
|
|
174
194
|
valueFlags: ["--home", "--dashboard-url", "--store"],
|
|
@@ -192,9 +212,10 @@ export function parseMemoryArgs(args) {
|
|
|
192
212
|
if (values.positionals.length > 1) {
|
|
193
213
|
throw new Error("memory accepts at most one action (install|status).");
|
|
194
214
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
215
|
+
// BLI-4047: no positional means "tell me what this noun does", not "install".
|
|
216
|
+
const action = values.positionals[0] ?? "help";
|
|
217
|
+
if (action !== "install" && action !== "status" && action !== "help") {
|
|
218
|
+
throw new Error("memory action must be install, status, help, log, search, save, update, or forget.");
|
|
198
219
|
}
|
|
199
220
|
return {
|
|
200
221
|
kind: "memory",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit memory search|save|update|forget` — the argument table (BLI-4047).
|
|
3
|
+
*
|
|
4
|
+
* BLI Memory was the one Tower surface with no command-line door: the browser
|
|
5
|
+
* had one, the `bli-memory` MCP server had one, and a person or a script at a
|
|
6
|
+
* terminal had nothing. These four verbs are the twins of that server's four
|
|
7
|
+
* tools, over the SAME `/api/memory/*` doors.
|
|
8
|
+
*
|
|
9
|
+
* Two rules this table exists to enforce, both of them the same discipline
|
|
10
|
+
* `cockpit docs create` and `cockpit notes paste` already follow:
|
|
11
|
+
*
|
|
12
|
+
* - **A body never travels on the command line.** `save`, `update` and
|
|
13
|
+
* `forget --content` read their text from stdin (or `--file`, which is the
|
|
14
|
+
* safest way to say it on Windows). An argument list is visible in `ps`,
|
|
15
|
+
* lands in a shell history file, and is quoted wrong on the first line that
|
|
16
|
+
* contains a newline. A QUERY is not a body — `search` takes one
|
|
17
|
+
* positional, the same way `cockpit search` and `cockpit docs list --query`
|
|
18
|
+
* do — but the text of a memory always is.
|
|
19
|
+
* - **The container tag is derived, not typed.** Omit `--container` and the
|
|
20
|
+
* tag comes from this repository, resolved by `@bli-cockpit/memory-mcp`'s
|
|
21
|
+
* own resolver, so a memory saved from the terminal lands in the same space
|
|
22
|
+
* the MCP server and the hooks write to. `--container` may be repeated on
|
|
23
|
+
* `search` alone, because searching two spaces is meaningful and saving
|
|
24
|
+
* into two is not.
|
|
25
|
+
*
|
|
26
|
+
* The verbs live in their own `kind` (`memory-store`) rather than widening
|
|
27
|
+
* `kind: "memory"`, because that command acts on THIS MACHINE (it registers an
|
|
28
|
+
* MCP server and three hooks) and these act on Tower. `memory-store.ts` runs
|
|
29
|
+
* them; `parseMemoryArgs` in `local-args-collector-status.ts` routes to here.
|
|
30
|
+
*/
|
|
31
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
|
|
32
|
+
export const MEMORY_STORE_ACTIONS = new Set([
|
|
33
|
+
"search",
|
|
34
|
+
"save",
|
|
35
|
+
"update",
|
|
36
|
+
"forget",
|
|
37
|
+
]);
|
|
38
|
+
const ALLOWED_FLAGS = [
|
|
39
|
+
"--home",
|
|
40
|
+
"--dashboard-url",
|
|
41
|
+
"--workspace",
|
|
42
|
+
"--repo",
|
|
43
|
+
"--container",
|
|
44
|
+
"--limit",
|
|
45
|
+
"--no-recent",
|
|
46
|
+
"--custom-id",
|
|
47
|
+
"--extract",
|
|
48
|
+
"--file",
|
|
49
|
+
"--content-stdin",
|
|
50
|
+
"--reason",
|
|
51
|
+
"--json",
|
|
52
|
+
];
|
|
53
|
+
const VALUE_FLAGS = [
|
|
54
|
+
"--home",
|
|
55
|
+
"--dashboard-url",
|
|
56
|
+
"--workspace",
|
|
57
|
+
"--repo",
|
|
58
|
+
"--container",
|
|
59
|
+
"--limit",
|
|
60
|
+
"--custom-id",
|
|
61
|
+
"--file",
|
|
62
|
+
"--reason",
|
|
63
|
+
];
|
|
64
|
+
export function parseMemoryStoreArgs(action, args) {
|
|
65
|
+
const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
|
|
66
|
+
const rest = values.positionals.slice(1);
|
|
67
|
+
const containers = values.flagValues.get("--container") ?? [];
|
|
68
|
+
if (action !== "search" && containers.length > 1) {
|
|
69
|
+
throw new Error(`memory ${action} takes at most one --container.`);
|
|
70
|
+
}
|
|
71
|
+
if (action !== "search" && (values.flags.has("--limit") || values.booleans.has("--no-recent"))) {
|
|
72
|
+
throw new Error("--limit and --no-recent only apply to memory search.");
|
|
73
|
+
}
|
|
74
|
+
if (action !== "save" && (values.flags.has("--custom-id") || values.booleans.has("--extract"))) {
|
|
75
|
+
throw new Error("--custom-id and --extract only apply to memory save.");
|
|
76
|
+
}
|
|
77
|
+
if (action !== "forget" && (values.flags.has("--reason") || values.booleans.has("--content-stdin"))) {
|
|
78
|
+
throw new Error("--reason and --content-stdin only apply to memory forget.");
|
|
79
|
+
}
|
|
80
|
+
if (values.flags.has("--file") && action !== "save" && action !== "update") {
|
|
81
|
+
throw new Error("--file only applies to memory save and memory update.");
|
|
82
|
+
}
|
|
83
|
+
const command = {
|
|
84
|
+
kind: "memory-store",
|
|
85
|
+
action,
|
|
86
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
87
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
88
|
+
workspace: optionalNonEmpty(workRootFlagValue(values)),
|
|
89
|
+
containerTags: containers
|
|
90
|
+
.map((tag) => tag.trim())
|
|
91
|
+
.filter((tag) => tag.length > 0),
|
|
92
|
+
json: values.booleans.has("--json"),
|
|
93
|
+
};
|
|
94
|
+
if (action === "search") {
|
|
95
|
+
// Every remaining positional joined, so `cockpit memory search what did we
|
|
96
|
+
// decide about pagination` needs no quotes — the same shape `cockpit
|
|
97
|
+
// search` and `cockpit cal find` already have.
|
|
98
|
+
const query = rest.join(" ").trim();
|
|
99
|
+
if (!query)
|
|
100
|
+
throw new Error("memory search needs a question, e.g. `cockpit memory search \"pagination rule\"`.");
|
|
101
|
+
return { ...command, action, query, limit: readLimit(values.flags.get("--limit")), includeRecent: !values.booleans.has("--no-recent") };
|
|
102
|
+
}
|
|
103
|
+
if (action === "save") {
|
|
104
|
+
if (rest.length > 0) {
|
|
105
|
+
throw new Error("memory save reads the memory from stdin or --file, never from the command line: `echo \"...\" | cockpit memory save`.");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
...command,
|
|
109
|
+
action,
|
|
110
|
+
customId: optionalNonEmpty(values.flags.get("--custom-id")),
|
|
111
|
+
extract: values.booleans.has("--extract"),
|
|
112
|
+
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (action === "update") {
|
|
116
|
+
const memoryId = optionalNonEmpty(rest[0]);
|
|
117
|
+
if (!memoryId)
|
|
118
|
+
throw new Error("memory update needs the memory id `cockpit memory search` printed.");
|
|
119
|
+
if (rest.length > 1) {
|
|
120
|
+
throw new Error("memory update takes one id; the replacement text comes from stdin or --file, never from the command line.");
|
|
121
|
+
}
|
|
122
|
+
return { ...command, action, memoryId, filePath: optionalNonEmpty(values.flags.get("--file")) };
|
|
123
|
+
}
|
|
124
|
+
const memoryId = optionalNonEmpty(rest[0]);
|
|
125
|
+
if (rest.length > 1)
|
|
126
|
+
throw new Error("memory forget takes one id, or --content-stdin with a --container.");
|
|
127
|
+
const contentStdin = values.booleans.has("--content-stdin");
|
|
128
|
+
if (memoryId && contentStdin) {
|
|
129
|
+
throw new Error("memory forget takes an id OR --content-stdin, never both — they name different rows.");
|
|
130
|
+
}
|
|
131
|
+
if (!memoryId && !contentStdin) {
|
|
132
|
+
throw new Error("memory forget needs an id, or --content-stdin plus --container to forget by exact text.");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...command,
|
|
136
|
+
action,
|
|
137
|
+
memoryId,
|
|
138
|
+
contentStdin,
|
|
139
|
+
reason: optionalNonEmpty(values.flags.get("--reason")),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** 1..20, the same window the MCP tool and the door both accept. */
|
|
143
|
+
function readLimit(value) {
|
|
144
|
+
if (value === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
const parsed = Number.parseInt(value, 10);
|
|
147
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 20) {
|
|
148
|
+
throw new Error("--limit must be between 1 and 20.");
|
|
149
|
+
}
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `cockpit memory` family's command shapes (BLI-4047).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `local-command-shapes.ts` the same way `SearchCommandShape` and
|
|
5
|
+
* the product shapes were: that file crossed the repo's 500-line band floor
|
|
6
|
+
* when the store verbs arrived, and one noun's shapes are exactly the kind of
|
|
7
|
+
* thing that reads better in its own file than as a third of a 540-line union.
|
|
8
|
+
*
|
|
9
|
+
* Three kinds, and the difference between them is the point:
|
|
10
|
+
*
|
|
11
|
+
* `memory` install / status / help — acts on THIS MACHINE, writing an
|
|
12
|
+
* MCP registration and three hooks into a home directory.
|
|
13
|
+
* `memory-store` search / save / update / forget — acts on TOWER, over
|
|
14
|
+
* `/api/memory/*`, the same doors the `bli-memory` MCP
|
|
15
|
+
* server and the browser call.
|
|
16
|
+
* `memory-log` the agent-experience opinion, part local file part Tower.
|
|
17
|
+
*
|
|
18
|
+
* They are separate kinds rather than one widening `action` union because they
|
|
19
|
+
* refuse different things, run on different credentials, and one of them can
|
|
20
|
+
* be run unasked by a scheduler while the others never are.
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
|
@@ -555,15 +555,32 @@ export function localSubcommandHelp(command) {
|
|
|
555
555
|
[
|
|
556
556
|
"memory",
|
|
557
557
|
[
|
|
558
|
-
"Usage: cockpit memory [
|
|
559
|
-
"",
|
|
560
|
-
"
|
|
558
|
+
"Usage: cockpit memory [search|save|update|forget|install|status|log] [flags]",
|
|
559
|
+
"",
|
|
560
|
+
"The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
|
|
561
|
+
" search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
|
|
562
|
+
" repeat --container to search more than one space; omit it for this repository's own.",
|
|
563
|
+
" save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>]",
|
|
564
|
+
" the memory comes from stdin or --file, never from the command line:",
|
|
565
|
+
" `echo \"Edward decided X on 2026-09-10\" | cockpit memory save`.",
|
|
566
|
+
" --custom-id makes a repeat save update instead of duplicating;",
|
|
567
|
+
" --extract distils atomic facts first (one model call) instead of storing as given.",
|
|
568
|
+
" update <id> [--file <path>] replacement text on stdin; the old row is kept as history.",
|
|
569
|
+
" forget <id> [--reason <label>]",
|
|
570
|
+
" forget --content-stdin --container <tag> [--reason <label>] forget by exact text.",
|
|
571
|
+
"Refusals keep the door's own label — needs_rls_client, unknown_memory,",
|
|
572
|
+
"already_superseded, no_match — and --json prints the door's answer unchanged.",
|
|
573
|
+
"",
|
|
574
|
+
"BARE `cockpit memory` PRINTS THIS LIST AND DOES NOTHING ELSE (BLI-4047).",
|
|
575
|
+
"It used to run the installer; installing is now `cockpit memory install`.",
|
|
576
|
+
"",
|
|
577
|
+
"install/status register BLI Memory on this machine: the `bli-memory` MCP server plus the",
|
|
561
578
|
"recall/save hooks, for Claude Code (~/.claude.json, ~/.claude/settings.json)",
|
|
562
579
|
"and for Codex (~/.codex/config.toml, ~/.codex/skills/bli-memory/).",
|
|
563
580
|
"Idempotent: it merges with what is already there, never duplicates its own",
|
|
564
581
|
"entries, and reads the stored config back before reporting success.",
|
|
565
582
|
"`do-everything` runs it, and the sync tick re-runs it at most once a day.",
|
|
566
|
-
"
|
|
583
|
+
"See docs/runbooks/bli-memory-install.md.",
|
|
567
584
|
"log appends an opinion to ~/.codex/AGENT-EXPERIENCE.md and posts it to Tower.",
|
|
568
585
|
"Use --reason-stdin instead of a quoted reason; --json reports shipped status.",
|
|
569
586
|
"Reasons: one line, max 500 characters; no prompts, memory bodies or secrets.",
|
|
@@ -88,7 +88,7 @@ export function localCommandHelp(command) {
|
|
|
88
88
|
" cockpit serve [--port <port>] [--workspace <path>]",
|
|
89
89
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
90
90
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
91
|
-
" cockpit memory [install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
91
|
+
" cockpit memory [search \"<question>\"|save|update <id>|forget <id>|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
92
92
|
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
93
93
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
94
94
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -33,6 +33,7 @@ import { runAutostart } from "./autostart-command.js";
|
|
|
33
33
|
import { runAgentRules } from "./agent-rules-command.js";
|
|
34
34
|
import { runMemoryLog } from "./memory-log.js";
|
|
35
35
|
import { runMemoryInstall } from "./memory-install.js";
|
|
36
|
+
import { runMemoryStore, runMemoryVerbList } from "./memory-store.js";
|
|
36
37
|
import { runClean } from "./clean.js";
|
|
37
38
|
import { runDocs } from "./docs.js";
|
|
38
39
|
import { runMsg } from "./msg.js";
|
|
@@ -148,7 +149,14 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
148
149
|
case "memory-log":
|
|
149
150
|
return await runMemoryLog(command, io);
|
|
150
151
|
case "memory":
|
|
152
|
+
// BLI-4047: a bare `cockpit memory` says what the noun does. It used
|
|
153
|
+
// to install; `install` is still one word away and every unattended
|
|
154
|
+
// caller asks for it by name in code.
|
|
155
|
+
if (command.action === "help")
|
|
156
|
+
return runMemoryVerbList(io, command.json);
|
|
151
157
|
return await runMemoryInstall(command, io);
|
|
158
|
+
case "memory-store":
|
|
159
|
+
return await runMemoryStore(command, io);
|
|
152
160
|
case "clean":
|
|
153
161
|
return await runClean(command, io);
|
|
154
162
|
case "docs":
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit memory search|save|update|forget` — BLI Memory at a terminal
|
|
3
|
+
* (BLI-4047).
|
|
4
|
+
*
|
|
5
|
+
* Memory was the one Tower surface with no command-line door. The browser had
|
|
6
|
+
* one, the `bli-memory` MCP server had four tools, and a person or a script
|
|
7
|
+
* had nothing — so a machine without an agent host could not read or write the
|
|
8
|
+
* store at all, and `cockpit memory` typed on its own registered an MCP server
|
|
9
|
+
* instead of saying what the noun does. These four verbs are the twins of that
|
|
10
|
+
* server's four tools.
|
|
11
|
+
*
|
|
12
|
+
* **The same doors, never a second store.** `POST /api/memory/{search,save,
|
|
13
|
+
* update,forget}`, with this machine's collector device token, through
|
|
14
|
+
* `agent-door.ts` — the identical route the MCP server calls in
|
|
15
|
+
* `packages/cockpit-memory-mcp/src/server.ts` and the browser calls from
|
|
16
|
+
* `lib/memory/agent-memories/`. Nothing here talks to Postgres, embeds
|
|
17
|
+
* anything, or knows what a container alias is; every ranking, redaction and
|
|
18
|
+
* lifecycle decision stays server-side where its one implementation lives.
|
|
19
|
+
*
|
|
20
|
+
* **A refusal keeps the door's own reason label.** `needs_rls_client`,
|
|
21
|
+
* `unknown_memory`, `already_forgotten`, `already_superseded`,
|
|
22
|
+
* `needs_container_tag`, `no_match`, `custom_id_conflict`, `search_timed_out`
|
|
23
|
+
* — printed verbatim by `failAgentDoor`, never rephrased here. A CLI that
|
|
24
|
+
* invents its own prose for a server's refusal is a CLI whose error messages
|
|
25
|
+
* lie one release later.
|
|
26
|
+
*
|
|
27
|
+
* **The container tag is `@bli-cockpit/memory-mcp`'s, not a second copy.**
|
|
28
|
+
* `resolveContainerTag` is imported from that package rather than
|
|
29
|
+
* reimplemented, so a memory saved by `cockpit memory save` lands in exactly
|
|
30
|
+
* the space the MCP server and the three hooks read from. The scheme is the
|
|
31
|
+
* vendor plugin's byte for byte and two implementations of it would strand
|
|
32
|
+
* memories in a space nothing asks about — see that module's header.
|
|
33
|
+
*
|
|
34
|
+
* **A body never comes from the command line.** `save`, `update` and
|
|
35
|
+
* `forget --content-stdin` read stdin (or `--file`). Same discipline as
|
|
36
|
+
* `cockpit docs create` and `cockpit notes paste`, and for the same reasons:
|
|
37
|
+
* `ps` shows an argument list, shell history keeps it, and a memory with a
|
|
38
|
+
* newline in it cannot be quoted correctly anyway.
|
|
39
|
+
*/
|
|
40
|
+
import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
|
|
41
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
42
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
43
|
+
import { readNoteFile } from "./notes-file.js";
|
|
44
|
+
const TAG = "[memory cli]";
|
|
45
|
+
const SEARCH_DEADLINE_MS = 60_000;
|
|
46
|
+
/** `--extract` is one model call, sometimes two; the door waits for it when asked to. */
|
|
47
|
+
const WRITE_DEADLINE_MS = 120_000;
|
|
48
|
+
/** The door's own ceiling on a memory (`saveMemoryInput`, 200k characters). */
|
|
49
|
+
const BODY_MAX_CHARS = 200_000;
|
|
50
|
+
/** The door's own ceiling on forget-by-text. */
|
|
51
|
+
const FORGET_MAX_CHARS = 10_000;
|
|
52
|
+
export async function runMemoryStore(command, io) {
|
|
53
|
+
const door = await openAgentDoor("memory", command, io);
|
|
54
|
+
switch (command.action) {
|
|
55
|
+
case "search":
|
|
56
|
+
return searchMemory(command, door);
|
|
57
|
+
case "save":
|
|
58
|
+
return saveMemory(command, door);
|
|
59
|
+
case "update":
|
|
60
|
+
return updateMemory(command, door);
|
|
61
|
+
case "forget":
|
|
62
|
+
return forgetMemory(command, door);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The verb list a bare `cockpit memory` prints (BLI-4047).
|
|
67
|
+
*
|
|
68
|
+
* It used to run the installer. Typing a noun to find out what it does is the
|
|
69
|
+
* single most common thing a person does with a new CLI, and this was the one
|
|
70
|
+
* command in the collector that answered that by writing five files in a home
|
|
71
|
+
* directory. Installing is still one word away, and every unattended caller
|
|
72
|
+
* asks for `install` by name in code.
|
|
73
|
+
*/
|
|
74
|
+
export function runMemoryVerbList(io, json) {
|
|
75
|
+
if (json) {
|
|
76
|
+
writeLine(io.stdout, JSON.stringify({
|
|
77
|
+
ok: true,
|
|
78
|
+
noun: "memory",
|
|
79
|
+
machine_verbs: ["install", "status"],
|
|
80
|
+
store_verbs: ["search", "save", "update", "forget"],
|
|
81
|
+
log_verbs: ["log"],
|
|
82
|
+
}));
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
for (const line of [
|
|
86
|
+
"cockpit memory — BLI Memory: the store, and this machine's registration.",
|
|
87
|
+
"",
|
|
88
|
+
"The store (Tower, over /api/memory/*):",
|
|
89
|
+
" cockpit memory search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent] [--json]",
|
|
90
|
+
" cockpit memory save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>] [--json]",
|
|
91
|
+
" the memory comes from stdin, or --file; never from the command line",
|
|
92
|
+
" cockpit memory update <id> [--file <path>] [--json] replacement text on stdin",
|
|
93
|
+
" cockpit memory forget <id> [--reason <label>] [--json]",
|
|
94
|
+
" cockpit memory forget --content-stdin --container <tag> [--reason <label>] [--json]",
|
|
95
|
+
"",
|
|
96
|
+
"This machine:",
|
|
97
|
+
" cockpit memory install [--dry-run] [--json] register the MCP server and the three hooks",
|
|
98
|
+
" cockpit memory status [--json] read the stored config back",
|
|
99
|
+
"",
|
|
100
|
+
"The experience log:",
|
|
101
|
+
" cockpit memory log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"",
|
|
102
|
+
"",
|
|
103
|
+
"Omit --container and this repository's own memory space is used — the same",
|
|
104
|
+
"space the bli-memory MCP server and its hooks read and write.",
|
|
105
|
+
]) {
|
|
106
|
+
writeLine(io.stdout, line);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* This repository's memory space, resolved by the MCP server's own resolver.
|
|
112
|
+
* `--container` wins; `--workspace` says which directory to derive it from.
|
|
113
|
+
*/
|
|
114
|
+
function containerTagsFor(command) {
|
|
115
|
+
if (command.containerTags.length > 0)
|
|
116
|
+
return command.containerTags;
|
|
117
|
+
return [resolveContainerTag({ cwd: command.workspace ?? process.cwd() }).containerTag];
|
|
118
|
+
}
|
|
119
|
+
async function searchMemory(command, door) {
|
|
120
|
+
const containerTags = containerTagsFor(command);
|
|
121
|
+
const answer = await askAgentDoor(door, {
|
|
122
|
+
path: "/api/memory/search",
|
|
123
|
+
method: "POST",
|
|
124
|
+
label: "memory search",
|
|
125
|
+
timeoutMs: SEARCH_DEADLINE_MS,
|
|
126
|
+
body: {
|
|
127
|
+
query: command.query,
|
|
128
|
+
containerTag: containerTags,
|
|
129
|
+
...(command.limit === undefined ? {} : { limit: command.limit }),
|
|
130
|
+
includeProfile: command.includeRecent !== false,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (!answer.ok)
|
|
134
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
135
|
+
const body = answer.body;
|
|
136
|
+
const results = body.results ?? [];
|
|
137
|
+
const recent = body.recent ?? [];
|
|
138
|
+
// Counts and labels only: a memory body never enters a log line.
|
|
139
|
+
writeLine(door.io.stderr, `${TAG} searched ${JSON.stringify({
|
|
140
|
+
requested_containers: containerTags.length,
|
|
141
|
+
searched_containers: body.containerTags?.length ?? containerTags.length,
|
|
142
|
+
results: results.length,
|
|
143
|
+
recent: recent.length,
|
|
144
|
+
dropped_below_floor: body.dropped_below_floor ?? 0,
|
|
145
|
+
degraded: body.degraded ?? null,
|
|
146
|
+
})}`);
|
|
147
|
+
if (door.json)
|
|
148
|
+
return emitAgentDoor(door, answer.body);
|
|
149
|
+
if (results.length === 0) {
|
|
150
|
+
// The distinction the whole store is built on: silence is not an outage.
|
|
151
|
+
// A failure took the `!answer.ok` branch above and named itself there.
|
|
152
|
+
writeLine(door.io.stdout, "No memories matched. The record is silent on this — it does not mean the store is down.");
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
for (const row of results) {
|
|
156
|
+
writeLine(door.io.stdout, `${similarityLabel(row)}${oneLine(row["memory"])}`);
|
|
157
|
+
writeLine(door.io.stdout, ` ${String(row["id"] ?? "")} ${String(row["containerTag"] ?? "")}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (recent.length > 0) {
|
|
161
|
+
writeLine(door.io.stdout, "");
|
|
162
|
+
writeLine(door.io.stdout, "Recent context:");
|
|
163
|
+
for (const row of recent)
|
|
164
|
+
writeLine(door.io.stdout, ` - ${oneLine(row["preview"] ?? row["memory"])}`);
|
|
165
|
+
}
|
|
166
|
+
if (body.degraded) {
|
|
167
|
+
writeLine(door.io.stdout, "");
|
|
168
|
+
writeLine(door.io.stdout, `This answer is thinner than usual (${body.degraded}) — one search channel did not run.`);
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
async function saveMemory(command, door) {
|
|
173
|
+
const containerTag = containerTagsFor(command)[0];
|
|
174
|
+
const body = await readBody(command, door, BODY_MAX_CHARS, "A memory");
|
|
175
|
+
if (!body.ok)
|
|
176
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
177
|
+
if (!body.text.trim()) {
|
|
178
|
+
return failAgentDoor(door, TAG, "content_empty", "There is nothing to save. Pipe the memory in: `echo \"...\" | cockpit memory save`, or pass --file.");
|
|
179
|
+
}
|
|
180
|
+
const answer = await askAgentDoor(door, {
|
|
181
|
+
path: "/api/memory/save",
|
|
182
|
+
method: "POST",
|
|
183
|
+
label: "memory save",
|
|
184
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
185
|
+
body: {
|
|
186
|
+
content: body.text,
|
|
187
|
+
containerTag,
|
|
188
|
+
...(command.customId ? { customId: command.customId } : {}),
|
|
189
|
+
mode: command.extract ? "extract" : "verbatim",
|
|
190
|
+
// Same reason the MCP tool sets it: a caller that ASKED for distillation
|
|
191
|
+
// is waiting for the answer, so take the synchronous branch instead of
|
|
192
|
+
// the 202 the Stop hook wants.
|
|
193
|
+
...(command.extract ? { wait: true } : {}),
|
|
194
|
+
sourceRef: { kind: "cli" },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
if (!answer.ok)
|
|
198
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
199
|
+
const saved = answer.body;
|
|
200
|
+
writeLine(door.io.stderr, `${TAG} saved ${JSON.stringify({
|
|
201
|
+
outcome: saved["outcome"] ?? null,
|
|
202
|
+
chars: body.text.length,
|
|
203
|
+
embedded: saved["embedded"] ?? null,
|
|
204
|
+
embed_skipped_reason: saved["embedSkippedReason"] ?? null,
|
|
205
|
+
extract: saved["extract"] ?? null,
|
|
206
|
+
masked_spans: saved["maskedSpans"] ?? 0,
|
|
207
|
+
})}`);
|
|
208
|
+
if (door.json)
|
|
209
|
+
return emitAgentDoor(door, answer.body);
|
|
210
|
+
// The container the SERVER used, which is the canonical one when this
|
|
211
|
+
// repository has more than one tag on the shelf; saying the tag we sent
|
|
212
|
+
// would name a space the row is not in.
|
|
213
|
+
const landedIn = String(saved["containerTag"] ?? containerTag);
|
|
214
|
+
writeLine(door.io.stdout, `Memory ${String(saved["outcome"] ?? "created")} in ${landedIn} (id: ${String(saved["id"] ?? "—")}).`);
|
|
215
|
+
if (saved["embedded"] === false && saved["embedSkippedReason"]) {
|
|
216
|
+
writeLine(door.io.stdout, `Stored without a search vector (${String(saved["embedSkippedReason"])}): findable by keyword, not yet by meaning.`);
|
|
217
|
+
}
|
|
218
|
+
if (saved["extract"] === "fell_back_verbatim") {
|
|
219
|
+
writeLine(door.io.stdout, `Saved as written; extraction failed (${String(saved["extractFailureReason"] ?? "unknown")}).`);
|
|
220
|
+
}
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
async function updateMemory(command, door) {
|
|
224
|
+
const body = await readBody(command, door, BODY_MAX_CHARS, "A memory");
|
|
225
|
+
if (!body.ok)
|
|
226
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
227
|
+
if (!body.text.trim()) {
|
|
228
|
+
return failAgentDoor(door, TAG, "content_empty", "An update needs replacement text on stdin or --file. To remove a memory, use `cockpit memory forget`.");
|
|
229
|
+
}
|
|
230
|
+
const answer = await askAgentDoor(door, {
|
|
231
|
+
path: "/api/memory/update",
|
|
232
|
+
method: "POST",
|
|
233
|
+
label: "memory update",
|
|
234
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
235
|
+
body: { id: command.memoryId, content: body.text },
|
|
236
|
+
});
|
|
237
|
+
if (!answer.ok)
|
|
238
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
239
|
+
const updated = answer.body;
|
|
240
|
+
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({
|
|
241
|
+
chars: body.text.length,
|
|
242
|
+
re_embedded: updated["reEmbedded"] ?? null,
|
|
243
|
+
embed_skipped_reason: updated["embedSkippedReason"] ?? null,
|
|
244
|
+
})}`);
|
|
245
|
+
if (door.json)
|
|
246
|
+
return emitAgentDoor(door, answer.body);
|
|
247
|
+
writeLine(door.io.stdout, `Updated. The new memory is ${String(updated["id"] ?? "")}, and ${String(updated["supersededId"] ?? command.memoryId ?? "")} is kept as its previous version.`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
async function forgetMemory(command, door) {
|
|
251
|
+
let content;
|
|
252
|
+
if (command.contentStdin) {
|
|
253
|
+
const body = await readBody(command, door, FORGET_MAX_CHARS, "Text to forget");
|
|
254
|
+
if (!body.ok)
|
|
255
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
256
|
+
if (!body.text.trim()) {
|
|
257
|
+
return failAgentDoor(door, TAG, "needs_id_or_content", "Nothing arrived on stdin to forget.");
|
|
258
|
+
}
|
|
259
|
+
content = body.text;
|
|
260
|
+
}
|
|
261
|
+
const answer = await askAgentDoor(door, {
|
|
262
|
+
path: "/api/memory/forget",
|
|
263
|
+
method: "POST",
|
|
264
|
+
label: "memory forget",
|
|
265
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
266
|
+
body: {
|
|
267
|
+
...(command.memoryId ? { id: command.memoryId } : {}),
|
|
268
|
+
...(content ? { content } : {}),
|
|
269
|
+
// Only defaulted for a text match, which is the case that would
|
|
270
|
+
// otherwise sweep every space this caller can read. An id needs none.
|
|
271
|
+
...(content ? { containerTag: containerTagsFor(command)[0] } : {}),
|
|
272
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
if (!answer.ok)
|
|
276
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
277
|
+
const result = answer.body;
|
|
278
|
+
writeLine(door.io.stderr, `${TAG} forgot ${JSON.stringify({ count: result.count ?? 0, by: content ? "content" : "id" })}`);
|
|
279
|
+
if (door.json)
|
|
280
|
+
return emitAgentDoor(door, answer.body);
|
|
281
|
+
// The door answers with WHAT it removed, deliberately: a bare "done" is the
|
|
282
|
+
// only shape in which "forget X" quietly removing Y goes unnoticed.
|
|
283
|
+
writeLine(door.io.stdout, result.message ?? `Forgotten (${result.count ?? 0}).`);
|
|
284
|
+
for (const row of result.forgotten ?? []) {
|
|
285
|
+
const entry = row;
|
|
286
|
+
writeLine(door.io.stdout, ` ${String(entry["id"] ?? "")} ${oneLine(entry["textPreview"])}`);
|
|
287
|
+
}
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
/** A body from `--file`, else stdin. An interactive terminal yields nothing rather than hanging. */
|
|
291
|
+
async function readBody(command, door, maxChars, what) {
|
|
292
|
+
if (command.filePath) {
|
|
293
|
+
const read = await readNoteFile(command.filePath);
|
|
294
|
+
if (!read.ok) {
|
|
295
|
+
return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, text: read.bytes.toString("utf8") };
|
|
298
|
+
}
|
|
299
|
+
if (isInteractiveStdin(door.io))
|
|
300
|
+
return { ok: true, text: "" };
|
|
301
|
+
try {
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
text: await readPipedText(door.io.stdin, {
|
|
305
|
+
maxChars,
|
|
306
|
+
overflowMessage: `${what} is limited to ${maxChars} characters.`,
|
|
307
|
+
}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
reason: "content_too_long",
|
|
314
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** `[87%] ` when the row was found by meaning, empty when it was a keyword hit. */
|
|
319
|
+
function similarityLabel(row) {
|
|
320
|
+
const channels = row["channels"];
|
|
321
|
+
if (Array.isArray(channels) && !channels.includes("semantic"))
|
|
322
|
+
return "";
|
|
323
|
+
const similarity = Number(row["similarity"]);
|
|
324
|
+
return Number.isFinite(similarity) && similarity >= 0 && similarity <= 1
|
|
325
|
+
? `[${Math.round(similarity * 100)}%] `
|
|
326
|
+
: "";
|
|
327
|
+
}
|
|
328
|
+
/** One terminal line: newlines folded, so a long memory cannot break the listing. */
|
|
329
|
+
function oneLine(value) {
|
|
330
|
+
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
331
|
+
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.98");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* without saying why.
|
|
14
14
|
*/
|
|
15
15
|
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
16
|
+
import { isDeliveryHoldReason } from "./session-sync-hold.js";
|
|
16
17
|
import { sourceScanFailureReason } from "./session-sync-scan.js";
|
|
17
18
|
/**
|
|
18
19
|
* The label used when a sync fails and nothing on the way there said why.
|
|
@@ -45,6 +46,15 @@ export function createSyncFailureLedger() {
|
|
|
45
46
|
* The spooled reason is the most specific thing anyone has, so it leads, and it
|
|
46
47
|
* names the worktree it belongs to — a fleet failure is usually one repo, and
|
|
47
48
|
* "which one" is the first question asked.
|
|
49
|
+
*
|
|
50
|
+
* BLI-4022: `delivery_backoff_holding` is the one raw-evidence reason that does
|
|
51
|
+
* NOT come in here. An object inside its own retry window was withheld on
|
|
52
|
+
* purpose and will be offered again; filing that as a failed tick is what
|
|
53
|
+
* painted Ian's Mac mini red on eight consecutive ticks while every session it
|
|
54
|
+
* collected landed. The hold is still reported — it becomes the tick's named
|
|
55
|
+
* hold in `session-sync-hold.ts` and rides the same receipt — and a tick that
|
|
56
|
+
* ALSO failed for a real reason still fails on that reason. Nothing is
|
|
57
|
+
* silenced; only the verdict changed.
|
|
48
58
|
*/
|
|
49
59
|
export function recordWorktreeDeliveryFailures(ledger, outcomes) {
|
|
50
60
|
for (const { worktree, sync } of outcomes) {
|
|
@@ -61,12 +71,16 @@ export function recordWorktreeDeliveryFailures(ledger, outcomes) {
|
|
|
61
71
|
});
|
|
62
72
|
}
|
|
63
73
|
for (const reason of sync.raw_evidence_failure_reasons ?? []) {
|
|
74
|
+
if (isDeliveryHoldReason(reason))
|
|
75
|
+
continue;
|
|
64
76
|
ledger.add({
|
|
65
77
|
label: "raw_evidence_upload_failed",
|
|
66
78
|
rendered: `raw_evidence:${reason}`,
|
|
67
79
|
});
|
|
68
80
|
}
|
|
69
81
|
for (const reason of sync.raw_evidence_retry_reasons ?? []) {
|
|
82
|
+
if (isDeliveryHoldReason(reason))
|
|
83
|
+
continue;
|
|
70
84
|
ledger.add({
|
|
71
85
|
label: "raw_evidence_retry_required",
|
|
72
86
|
rendered: `raw_evidence_retry:${reason}`,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
|
|
13
13
|
import { countSessionsNewThisTick, countSessionsPendingUpload, } from "./session-sync-counters.js";
|
|
14
|
+
import { logDeliveryHold, summarizeDeliveryHold } from "./session-sync-hold.js";
|
|
14
15
|
/**
|
|
15
16
|
* Decide whether this tick failed, and record every condition that decided it.
|
|
16
17
|
*
|
|
@@ -44,12 +45,20 @@ export function decideSyncHealth(options) {
|
|
|
44
45
|
if (nothingInRoot !== null && notice) {
|
|
45
46
|
announceNothingInRoot(nothingInRoot, options.collectionRootCount);
|
|
46
47
|
}
|
|
48
|
+
// BLI-4022. Computed after `ok`, and never allowed to change it: a hold is
|
|
49
|
+
// what this tick withheld on purpose, not a verdict on the run. It rides an
|
|
50
|
+
// `ok` receipt as the tick's reason and a failing one beside the reasons that
|
|
51
|
+
// failed it, so the board can say what is waiting in either case.
|
|
52
|
+
const hold = summarizeDeliveryHold(outcomes);
|
|
53
|
+
if (hold && ok)
|
|
54
|
+
logDeliveryHold(hold);
|
|
47
55
|
const records = ledger.sortedRecords();
|
|
48
56
|
return {
|
|
49
57
|
ok,
|
|
50
58
|
failure_reasons: records.map((record) => record.rendered),
|
|
51
59
|
failure_records: records,
|
|
52
60
|
notice,
|
|
61
|
+
hold,
|
|
53
62
|
sessions_outside_root: sessionsOutsideRoot,
|
|
54
63
|
sessions_new_this_tick: countSessionsNewThisTick({
|
|
55
64
|
sessions: options.sessions,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this tick is HOLDING, as opposed to what it got wrong (BLI-4022).
|
|
3
|
+
*
|
|
4
|
+
* The sibling `session-sync-failures.ts` is the one place a sync writes down
|
|
5
|
+
* that it FAILED. This is the one place it writes down that it deliberately
|
|
6
|
+
* withheld something and when it will try again — a different question with a
|
|
7
|
+
* different answer, kept in a different file so neither can quietly become the
|
|
8
|
+
* other.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately writes no failure label of any kind: `sync-health-class.test.ts`
|
|
11
|
+
* reads every `session-sync*.ts` sibling and demands that only the failure
|
|
12
|
+
* ledger writes one, and this file is not it.
|
|
13
|
+
*/
|
|
14
|
+
import { formatDeliveryHoldNotice, } from "../delivery-hold-notice.js";
|
|
15
|
+
import { DELIVERY_BACKOFF_HOLDING_REASON } from "../raw-evidence-staging.js";
|
|
16
|
+
/**
|
|
17
|
+
* Is this reason a delivery hold rather than a delivery failure?
|
|
18
|
+
*
|
|
19
|
+
* Exact match against the label telemetry-core owns — the same discipline the
|
|
20
|
+
* failure classifier follows. A reason that merely CONTAINS the word "backoff"
|
|
21
|
+
* is not evidence of anything (BLI-3551's lesson, applied to the other side of
|
|
22
|
+
* the same fence).
|
|
23
|
+
*/
|
|
24
|
+
export function isDeliveryHoldReason(reason) {
|
|
25
|
+
return reason === DELIVERY_BACKOFF_HOLDING_REASON;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Fold every worktree's held objects into one hold for the tick, or null.
|
|
29
|
+
*
|
|
30
|
+
* The count is summed because a tick can sync several worktrees and each one
|
|
31
|
+
* reports its own held objects. The AGE takes the oldest across all of them and
|
|
32
|
+
* the NEXT ATTEMPT the earliest, because both are promises about the whole
|
|
33
|
+
* machine: the operator asks "how long has anything been stuck" and "when does
|
|
34
|
+
* anything move", not "which worktree".
|
|
35
|
+
*
|
|
36
|
+
* Returns null when nothing is held, so no caller can announce a hold of
|
|
37
|
+
* nothing — a status line that appears on every tick is one people stop
|
|
38
|
+
* reading.
|
|
39
|
+
*/
|
|
40
|
+
export function summarizeDeliveryHold(outcomes) {
|
|
41
|
+
let objectCount = 0;
|
|
42
|
+
let oldestFirstFailedAt = null;
|
|
43
|
+
let nextAttemptAt = null;
|
|
44
|
+
let lastReason = null;
|
|
45
|
+
for (const { sync } of outcomes) {
|
|
46
|
+
const held = sync.raw_evidence_delivery_held_count ?? 0;
|
|
47
|
+
if (held <= 0)
|
|
48
|
+
continue;
|
|
49
|
+
objectCount += held;
|
|
50
|
+
const first = sync.raw_evidence_held_oldest_failure_at ?? null;
|
|
51
|
+
if (first && (!oldestFirstFailedAt || first < oldestFirstFailedAt)) {
|
|
52
|
+
oldestFirstFailedAt = first;
|
|
53
|
+
// The reason travels WITH the object it belongs to. A reason taken from
|
|
54
|
+
// one worktree and an age from another would describe no object at all.
|
|
55
|
+
lastReason = sync.raw_evidence_held_last_reason ?? null;
|
|
56
|
+
}
|
|
57
|
+
const next = sync.raw_evidence_held_next_attempt_at ?? null;
|
|
58
|
+
if (next && (!nextAttemptAt || next < nextAttemptAt))
|
|
59
|
+
nextAttemptAt = next;
|
|
60
|
+
}
|
|
61
|
+
if (objectCount <= 0)
|
|
62
|
+
return null;
|
|
63
|
+
const notice = formatDeliveryHoldNotice({
|
|
64
|
+
objectCount,
|
|
65
|
+
oldestFirstFailedAt,
|
|
66
|
+
nextAttemptAt,
|
|
67
|
+
lastReason,
|
|
68
|
+
});
|
|
69
|
+
if (!notice)
|
|
70
|
+
return null;
|
|
71
|
+
return { objectCount, oldestFirstFailedAt, nextAttemptAt, lastReason, notice };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Say out loud, once per tick, that bytes are being withheld on purpose.
|
|
75
|
+
*
|
|
76
|
+
* `logEvidenceHeldByBackoff` already names the objects at the moment they are
|
|
77
|
+
* partitioned off the wire; this is the RUN's line, so the tick that reports
|
|
78
|
+
* itself as held also explains itself on stderr where launchd captures it.
|
|
79
|
+
* Counts, instants and reason labels only.
|
|
80
|
+
*/
|
|
81
|
+
export function logDeliveryHold(hold) {
|
|
82
|
+
console.error("[session-sync] raw evidence held by delivery backoff, nothing else failed", JSON.stringify({
|
|
83
|
+
reason: hold.notice,
|
|
84
|
+
object_count: hold.objectCount,
|
|
85
|
+
oldest_first_failed_at: hold.oldestFirstFailedAt,
|
|
86
|
+
next_attempt_at: hold.nextAttemptAt,
|
|
87
|
+
last_reason: hold.lastReason,
|
|
88
|
+
next_action: "nothing until the next attempt is due; run `cockpit doctor` if it is already past",
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* class-lock test reads that file (BLI-3551).
|
|
26
26
|
* - `session-sync-health.ts` — the verdict, and the notice a quiet-but-working
|
|
27
27
|
* machine still emits.
|
|
28
|
+
* - `session-sync-hold.ts` — what the tick withheld ON PURPOSE and when it will
|
|
29
|
+
* try again (BLI-4022), which is a different question from what it got wrong
|
|
30
|
+
* and so lives in a different file from the failure ledger.
|
|
28
31
|
* - `session-sync-counters.ts` — the two honest heartbeat counters beside the
|
|
29
32
|
* raw scan count: sessions new since the last tick's cursor, and sessions
|
|
30
33
|
* still pending a durable pointer (BLI-3645).
|
|
@@ -49,6 +52,7 @@ export { isLiveSyncCollectableAttributionState, liveSyncCursorEntryRequiresRetry
|
|
|
49
52
|
export { sourceScanFailureReason, sourceScanRetryReason, } from "./session-sync-scan.js";
|
|
50
53
|
export { SYNC_FAILED_WITHOUT_REASON } from "./session-sync-failures.js";
|
|
51
54
|
export { nothingInRootCount } from "./session-sync-health.js";
|
|
55
|
+
export { isDeliveryHoldReason, summarizeDeliveryHold, } from "./session-sync-hold.js";
|
|
52
56
|
/**
|
|
53
57
|
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
54
58
|
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
@@ -126,6 +130,7 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
126
130
|
failure_reasons: health.failure_reasons,
|
|
127
131
|
failure_records: health.failure_records,
|
|
128
132
|
notice: health.notice,
|
|
133
|
+
hold: health.hold,
|
|
129
134
|
sessions_observed: sessions.length,
|
|
130
135
|
sessions_outside_root: health.sessions_outside_root,
|
|
131
136
|
sessions_new_this_tick: health.sessions_new_this_tick,
|
|
@@ -91,16 +91,25 @@ function standAsideForRunningSync(command, io, heldSince) {
|
|
|
91
91
|
* is the receipt that separates "this machine is alive and its operator
|
|
92
92
|
* works outside the approved roots" from "this machine is dead", which
|
|
93
93
|
* until now looked identical from the dashboard.
|
|
94
|
+
*
|
|
95
|
+
* BLI-4022 added the second thing an `ok` tick can say: `held_backoff:…`, the
|
|
96
|
+
* objects this tick withheld on purpose and when they may be offered again.
|
|
97
|
+
* The two can never collide — `nothing_in_root` requires that NO worktree
|
|
98
|
+
* synced at all (`nothingInRootCount`), and a hold requires a worktree that
|
|
99
|
+
* did — so one field carries whichever is true, and the notice wins if that
|
|
100
|
+
* ever stops being the case, because "there was nothing to collect" is the
|
|
101
|
+
* larger fact about the tick.
|
|
94
102
|
*/
|
|
95
|
-
function collectedReceipt(run) {
|
|
103
|
+
export function collectedReceipt(run) {
|
|
104
|
+
const reason = run.notice ?? run.holdNotice;
|
|
96
105
|
return {
|
|
97
106
|
exitCode: run.exitCode,
|
|
98
107
|
completion: {
|
|
99
108
|
step: "sync_complete",
|
|
100
109
|
status: "ok",
|
|
101
|
-
...(
|
|
110
|
+
...(reason ? { error_detail: reason } : {}),
|
|
102
111
|
},
|
|
103
|
-
heartbeat: { status: "ok", reason
|
|
112
|
+
heartbeat: { status: "ok", reason, ...heartbeatCounts(run) },
|
|
104
113
|
};
|
|
105
114
|
}
|
|
106
115
|
/**
|
|
@@ -111,8 +120,14 @@ function collectedReceipt(run) {
|
|
|
111
120
|
* failure rows carried a null detail and the real reason was reachable only
|
|
112
121
|
* by running `cockpit status` on the machine itself (BLI-2526).
|
|
113
122
|
*/
|
|
114
|
-
function failedReceipt(run) {
|
|
115
|
-
|
|
123
|
+
export function failedReceipt(run) {
|
|
124
|
+
// BLI-4022: the hold leads on a failing tick. `error_detail` is bounded, and
|
|
125
|
+
// an operator reading a red row still has to be able to see what is merely
|
|
126
|
+
// waiting — otherwise the held objects become invisible the moment anything
|
|
127
|
+
// else goes wrong, which is the opposite of the fix.
|
|
128
|
+
const reasonText = [run.holdNotice, ...run.failureReasons]
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join("; ");
|
|
116
131
|
// The bucket comes from the records the deciding branches wrote, not from
|
|
117
132
|
// this sentence (BLI-3551). The sentence is still the detail.
|
|
118
133
|
const errorCode = classifySyncFailureRecords(run.failureRecords);
|
|
@@ -141,6 +141,9 @@ function syncResult(run) {
|
|
|
141
141
|
failureReasons: run.ok ? [] : run.failure_reasons,
|
|
142
142
|
failureRecords: run.ok ? [] : run.failure_records,
|
|
143
143
|
notice: run.notice,
|
|
144
|
+
// BLI-4022: unlike the reasons, the hold survives a successful run — it is
|
|
145
|
+
// what the tick withheld, not why it failed.
|
|
146
|
+
holdNotice: run.hold?.notice ?? null,
|
|
144
147
|
sessionsObserved: run.sessions_observed,
|
|
145
148
|
sessionsOutsideRoot: run.sessions_outside_root,
|
|
146
149
|
sessionsNewThisTick: run.sessions_new_this_tick,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A HOLD is not a FAILURE — and this file is the one place that spells it.
|
|
3
|
+
*
|
|
4
|
+
* BLI-4022. `delivery_backoff_holding` means "this object is inside its own
|
|
5
|
+
* retry window, so this tick did not offer it". Until now that reason went
|
|
6
|
+
* into the sync's failure ledger like any other, so a machine collecting
|
|
7
|
+
* perfectly reported `sync_failed` on every tick for as long as the backoff
|
|
8
|
+
* held: Ian's Mac mini was RED on eight consecutive ticks from
|
|
9
|
+
* 2026-09-09T00:11Z with the detail
|
|
10
|
+
* `raw_evidence_retry:delivery_backoff_holding; raw_evidence:delivery_backoff_holding`
|
|
11
|
+
* while its sessions kept landing and its heartbeat kept moving.
|
|
12
|
+
*
|
|
13
|
+
* The honesty is unchanged. A held object IS missing collection and must never
|
|
14
|
+
* read as clean (that is the rule BLI-3066 bought). What changes is the
|
|
15
|
+
* OUTCOME: a tick whose only raw-evidence complaint is held objects reports a
|
|
16
|
+
* named hold instead of a failure, and the hold carries everything an operator
|
|
17
|
+
* needs to decide whether to care — how many objects, how long the oldest has
|
|
18
|
+
* been failing, when the next attempt is due, and the object's own last reason.
|
|
19
|
+
*
|
|
20
|
+
* ## The grammar
|
|
21
|
+
*
|
|
22
|
+
* One token, because it has to survive two narrow channels: the heartbeat's
|
|
23
|
+
* `last_sync_reason` (`^[a-z0-9_:.-]+$`, 120 chars, no spaces) and the receipt's
|
|
24
|
+
* `error_detail`. Fields are positional and colon-separated:
|
|
25
|
+
*
|
|
26
|
+
* held_backoff:<count>:<oldest_first_failed_at>:<next_attempt_at>:<last_reason>
|
|
27
|
+
* held_backoff:3:2026-08-14T17:38Z:2026-09-09T06:11Z:chunk_10_failed_http_500
|
|
28
|
+
*
|
|
29
|
+
* Timestamps are minute precision UTC; `-` stands for a field this machine
|
|
30
|
+
* could not fill. The last reason is bounded so the whole token fits the
|
|
31
|
+
* heartbeat's 120 characters — the count and the ages are what a colour is
|
|
32
|
+
* decided from, so the reason is the field that gives way.
|
|
33
|
+
*
|
|
34
|
+
* The collector WRITES the token and the dashboard's fleet board READS it
|
|
35
|
+
* (`lib/ops/fleet-liveness-device-verdict.ts`, through this package's
|
|
36
|
+
* `./delivery-hold` export). One grammar, one file: a second copy of this
|
|
37
|
+
* string is exactly how the collector's word and the server's word drift
|
|
38
|
+
* apart, which is why `DELIVERY_BACKOFF_HOLDING` itself is aliased from
|
|
39
|
+
* telemetry-core rather than spelled twice.
|
|
40
|
+
*/
|
|
41
|
+
/** The token's leading word. Greppable in a receipt, a log line or a DM. */
|
|
42
|
+
export const DELIVERY_HOLD_NOTICE_PREFIX = "held_backoff";
|
|
43
|
+
/** The heartbeat's own ceiling for `last_sync_reason`. The token fits inside it. */
|
|
44
|
+
export const DELIVERY_HOLD_NOTICE_MAX_CHARS = 120;
|
|
45
|
+
/**
|
|
46
|
+
* How long a hold may last before it stops being a wait and becomes a stuck
|
|
47
|
+
* object.
|
|
48
|
+
*
|
|
49
|
+
* The backoff schedule doubles from the scheduler's own 15-minute cadence to a
|
|
50
|
+
* six-hour ceiling (`EVIDENCE_DELIVERY_BACKOFF_MAX_MS`), so an object held
|
|
51
|
+
* longer than this has already been offered at least four more times at the
|
|
52
|
+
* ceiling and been refused every time. Nothing is going to change on its own,
|
|
53
|
+
* and "next retry at T — nothing to do until then" would be a promise the
|
|
54
|
+
* machine cannot keep. 24 hours is also the board's own `SILENT_AFTER_HOURS`,
|
|
55
|
+
* so both clocks agree on when a quiet thing stops being a blip.
|
|
56
|
+
*/
|
|
57
|
+
export const DELIVERY_HOLD_STALE_AFTER_HOURS = 24;
|
|
58
|
+
/** The most characters of an object's own reason label the token may carry. */
|
|
59
|
+
const MAX_HOLD_REASON_CHARS = 64;
|
|
60
|
+
/** A field this machine could not fill. Never omitted — the shape is positional. */
|
|
61
|
+
const ABSENT = "-";
|
|
62
|
+
/**
|
|
63
|
+
* Render the hold as the single token the heartbeat and the receipt carry.
|
|
64
|
+
*
|
|
65
|
+
* Returns null for a hold of nothing, so a caller cannot accidentally announce
|
|
66
|
+
* a hold that is not happening.
|
|
67
|
+
*/
|
|
68
|
+
export function formatDeliveryHoldNotice(notice) {
|
|
69
|
+
if (notice.objectCount <= 0)
|
|
70
|
+
return null;
|
|
71
|
+
const head = [
|
|
72
|
+
DELIVERY_HOLD_NOTICE_PREFIX,
|
|
73
|
+
String(Math.trunc(notice.objectCount)),
|
|
74
|
+
minuteStamp(notice.oldestFirstFailedAt),
|
|
75
|
+
minuteStamp(notice.nextAttemptAt),
|
|
76
|
+
].join(":");
|
|
77
|
+
const budget = Math.min(MAX_HOLD_REASON_CHARS, DELIVERY_HOLD_NOTICE_MAX_CHARS - head.length - 1);
|
|
78
|
+
return `${head}:${reasonWord(notice.lastReason, budget)}`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Find a hold token inside anything a receipt carries, and read it back.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately a SEARCH, not a whole-string match: on a tick that also failed
|
|
84
|
+
* for a real reason the token rides beside those reasons in the same
|
|
85
|
+
* `error_detail`, and the board must still be able to say what is held.
|
|
86
|
+
*/
|
|
87
|
+
export function parseDeliveryHoldNotice(text) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return null;
|
|
90
|
+
// The stamp shape is pinned rather than described by a character class: a
|
|
91
|
+
// class containing `:` is greedy across the field separator, and a parser
|
|
92
|
+
// that swallowed one field into the next reported an age of twenty-five
|
|
93
|
+
// years and a next attempt in 2001.
|
|
94
|
+
const stamp = String.raw `(?:-|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z)`;
|
|
95
|
+
const match = new RegExp(`${DELIVERY_HOLD_NOTICE_PREFIX}:(\\d+):(${stamp}):(${stamp}):([a-z0-9_-]+)`, "iu").exec(text);
|
|
96
|
+
if (!match)
|
|
97
|
+
return null;
|
|
98
|
+
const objectCount = Number.parseInt(match[1], 10);
|
|
99
|
+
if (!Number.isFinite(objectCount) || objectCount <= 0)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
objectCount,
|
|
103
|
+
oldestFirstFailedAt: readStamp(match[2]),
|
|
104
|
+
nextAttemptAt: readStamp(match[3]),
|
|
105
|
+
lastReason: match[4] === ABSENT ? null : match[4],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* How long the longest-held object has been failing, in hours.
|
|
110
|
+
*
|
|
111
|
+
* Null when the token carried no first-failure instant, which is a different
|
|
112
|
+
* fact from "zero hours" — an unknown age is never treated as a fresh hold.
|
|
113
|
+
*/
|
|
114
|
+
export function deliveryHoldAgeHours(notice, nowIso) {
|
|
115
|
+
if (!notice.oldestFirstFailedAt)
|
|
116
|
+
return null;
|
|
117
|
+
const from = Date.parse(notice.oldestFirstFailedAt);
|
|
118
|
+
const now = Date.parse(nowIso);
|
|
119
|
+
if (!Number.isFinite(from) || !Number.isFinite(now))
|
|
120
|
+
return null;
|
|
121
|
+
return (now - from) / 3_600_000;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Has this hold stopped being a wait?
|
|
125
|
+
*
|
|
126
|
+
* An age this reader could not compute counts as stale on purpose: the whole
|
|
127
|
+
* point of the amber sentence is the promise "next retry at T", and a hold
|
|
128
|
+
* whose age is unknown is one nobody can make that promise about.
|
|
129
|
+
*/
|
|
130
|
+
export function isDeliveryHoldStale(notice, nowIso) {
|
|
131
|
+
const hours = deliveryHoldAgeHours(notice, nowIso);
|
|
132
|
+
if (hours === null)
|
|
133
|
+
return true;
|
|
134
|
+
return hours > DELIVERY_HOLD_STALE_AFTER_HOURS;
|
|
135
|
+
}
|
|
136
|
+
/** `2026-09-09T06:11Z`, or `-` for an instant this machine does not have. */
|
|
137
|
+
function minuteStamp(iso) {
|
|
138
|
+
if (!iso)
|
|
139
|
+
return ABSENT;
|
|
140
|
+
const parsed = Date.parse(iso);
|
|
141
|
+
if (!Number.isFinite(parsed))
|
|
142
|
+
return ABSENT;
|
|
143
|
+
return `${new Date(parsed).toISOString().slice(0, 16)}Z`;
|
|
144
|
+
}
|
|
145
|
+
/** The inverse: a token field back to an ISO instant, or null when absent. */
|
|
146
|
+
function readStamp(field) {
|
|
147
|
+
if (field === ABSENT)
|
|
148
|
+
return null;
|
|
149
|
+
const parsed = Date.parse(field);
|
|
150
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A reason label narrowed to what the token's charset and budget allow.
|
|
154
|
+
*
|
|
155
|
+
* Truncation is marked by dropping characters, never by an ellipsis: the
|
|
156
|
+
* heartbeat's charset has no `…`, and a token the door rejects tells nobody
|
|
157
|
+
* anything at all.
|
|
158
|
+
*/
|
|
159
|
+
function reasonWord(reason, budget) {
|
|
160
|
+
if (!reason || budget <= 0)
|
|
161
|
+
return ABSENT;
|
|
162
|
+
const safe = reason.replace(/[^a-z0-9_]/giu, "_").replace(/^_+|_+$/gu, "");
|
|
163
|
+
if (safe.length === 0)
|
|
164
|
+
return ABSENT;
|
|
165
|
+
return safe.slice(0, budget);
|
|
166
|
+
}
|
|
@@ -25,6 +25,10 @@ import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
|
25
25
|
* Metadata only: content hashes, byte sizes, pack ids, pack-relative paths,
|
|
26
26
|
* reason labels, timestamps. Never content, never an absolute path.
|
|
27
27
|
*/
|
|
28
|
+
// BLI-4022. The stuck/held summary moved to its own sibling so this file
|
|
29
|
+
// stays under the readability band; it is still importable from here,
|
|
30
|
+
// which is the address every caller already knows.
|
|
31
|
+
export { summarizeStuckEvidence, } from "./raw-evidence-stuck-summary.js";
|
|
28
32
|
export const RAW_EVIDENCE_STAGING_FILENAME = "raw-evidence-staging.json";
|
|
29
33
|
/**
|
|
30
34
|
* Backoff schedule for an object whose delivery keeps failing.
|
|
@@ -207,33 +211,6 @@ export function evidenceSourceKey(options) {
|
|
|
207
211
|
: "unknown";
|
|
208
212
|
return `${options.kind}:${options.sessionId ?? "none"}:${source}`;
|
|
209
213
|
}
|
|
210
|
-
/**
|
|
211
|
-
* What `cockpit status` and the health receipt need in order to be unable to
|
|
212
|
-
* read green while an object has been failing for nine days.
|
|
213
|
-
*/
|
|
214
|
-
export function summarizeStuckEvidence(state, now) {
|
|
215
|
-
const entries = Object.values(state.delivery_attempts);
|
|
216
|
-
const reasons = new Set();
|
|
217
|
-
let heldCount = 0;
|
|
218
|
-
let maxAttempts = 0;
|
|
219
|
-
let oldest = null;
|
|
220
|
-
for (const entry of entries) {
|
|
221
|
-
reasons.add(entry.last_reason);
|
|
222
|
-
if (Date.parse(entry.next_attempt_at) > now.getTime())
|
|
223
|
-
heldCount += 1;
|
|
224
|
-
maxAttempts = Math.max(maxAttempts, entry.attempts);
|
|
225
|
-
if (!oldest || entry.first_failed_at.localeCompare(oldest) < 0) {
|
|
226
|
-
oldest = entry.first_failed_at;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return {
|
|
230
|
-
stuck_object_count: entries.length,
|
|
231
|
-
held_object_count: heldCount,
|
|
232
|
-
max_attempts: maxAttempts,
|
|
233
|
-
oldest_first_failed_at: oldest,
|
|
234
|
-
reasons: [...reasons].sort(),
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
214
|
/**
|
|
238
215
|
* Pack id derived from CONTENT, never from the clock.
|
|
239
216
|
*
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is anything stuck, and is anything being held right now?" — the two
|
|
3
|
+
* questions `cockpit status`, the health receipt and the fleet board ask of the
|
|
4
|
+
* delivery-attempt history, split out of `raw-evidence-staging.ts` (BLI-4022)
|
|
5
|
+
* so that file stays under the repo's readability band.
|
|
6
|
+
*
|
|
7
|
+
* The distinction this file exists to keep: an object with a live failure
|
|
8
|
+
* history is STUCK, and an object whose next attempt is still in the future is
|
|
9
|
+
* HELD. Every held object is stuck and most stuck objects are held, but the two
|
|
10
|
+
* answer different questions — "has anything been failing for nine days" versus
|
|
11
|
+
* "what did this tick withhold, and when does it try again" — and BLI-4022
|
|
12
|
+
* needed the second one, which nothing computed.
|
|
13
|
+
*
|
|
14
|
+
* Metadata only, like the rest of this family: content hashes, byte sizes,
|
|
15
|
+
* reason labels, timestamps. Never content, never an absolute path.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* What `cockpit status` and the health receipt need in order to be unable to
|
|
19
|
+
* read green while an object has been failing for nine days.
|
|
20
|
+
*/
|
|
21
|
+
export function summarizeStuckEvidence(state, now) {
|
|
22
|
+
const entries = Object.values(state.delivery_attempts);
|
|
23
|
+
const reasons = new Set();
|
|
24
|
+
let heldCount = 0;
|
|
25
|
+
let maxAttempts = 0;
|
|
26
|
+
let oldest = null;
|
|
27
|
+
// The held objects are tracked apart from the stuck ones, because a hold is
|
|
28
|
+
// judged on what is being withheld NOW and on when it may next be offered —
|
|
29
|
+
// not on every object that has ever failed here.
|
|
30
|
+
let heldOldest = null;
|
|
31
|
+
let heldNextAttemptAt = null;
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
reasons.add(entry.last_reason);
|
|
34
|
+
if (Date.parse(entry.next_attempt_at) > now.getTime()) {
|
|
35
|
+
heldCount += 1;
|
|
36
|
+
if (!heldOldest ||
|
|
37
|
+
entry.first_failed_at.localeCompare(heldOldest.first_failed_at) < 0) {
|
|
38
|
+
heldOldest = entry;
|
|
39
|
+
}
|
|
40
|
+
if (!heldNextAttemptAt ||
|
|
41
|
+
entry.next_attempt_at.localeCompare(heldNextAttemptAt) < 0) {
|
|
42
|
+
heldNextAttemptAt = entry.next_attempt_at;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
maxAttempts = Math.max(maxAttempts, entry.attempts);
|
|
46
|
+
if (!oldest || entry.first_failed_at.localeCompare(oldest) < 0) {
|
|
47
|
+
oldest = entry.first_failed_at;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
stuck_object_count: entries.length,
|
|
52
|
+
held_object_count: heldCount,
|
|
53
|
+
max_attempts: maxAttempts,
|
|
54
|
+
oldest_first_failed_at: oldest,
|
|
55
|
+
reasons: [...reasons].sort(),
|
|
56
|
+
held_oldest_first_failed_at: heldOldest?.first_failed_at ?? null,
|
|
57
|
+
held_next_attempt_at: heldNextAttemptAt,
|
|
58
|
+
held_last_reason: heldOldest?.last_reason ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -101,6 +101,12 @@ export function hasRetryableEvidenceGap(facts, outcomes) {
|
|
|
101
101
|
* backoff is missing collection right now. If it did not land here the sync
|
|
102
102
|
* would read clean while a transcript sat undelivered for nine days, which is
|
|
103
103
|
* the exact shape of BLI-3066.
|
|
104
|
+
*
|
|
105
|
+
* BLI-4022 did NOT change that. A hold still queues the retry and still travels
|
|
106
|
+
* to the status output; what changed is downstream, at the ledger in
|
|
107
|
+
* `commands/session-sync-failures.ts`, which no longer files it as a FAILED
|
|
108
|
+
* tick. Retryable and failed are two questions, and answering both with this
|
|
109
|
+
* one set is what painted a collecting machine red for eight ticks.
|
|
104
110
|
*/
|
|
105
111
|
function isRetryableEvidenceSkipReason(reason) {
|
|
106
112
|
if (reason === DELIVERY_BACKOFF_HOLDING_REASON)
|
|
@@ -201,5 +207,8 @@ export function summarizeRawEvidenceDelivery(built, outcomes, uploadedChunkCount
|
|
|
201
207
|
raw_evidence_stuck_object_count: stuck.stuck_object_count,
|
|
202
208
|
raw_evidence_max_delivery_attempts: stuck.max_attempts,
|
|
203
209
|
raw_evidence_oldest_delivery_failure_at: stuck.oldest_first_failed_at,
|
|
210
|
+
raw_evidence_held_oldest_failure_at: stuck.held_oldest_first_failed_at,
|
|
211
|
+
raw_evidence_held_next_attempt_at: stuck.held_next_attempt_at,
|
|
212
|
+
raw_evidence_held_last_reason: stuck.held_last_reason,
|
|
204
213
|
};
|
|
205
214
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.98",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.25",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.29",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.42"
|
|
33
33
|
}
|
|
34
34
|
}
|