mercury-agent 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/container/Dockerfile +1 -1
  2. package/container/Dockerfile.base +1 -1
  3. package/container/agent-package.json +1 -1
  4. package/docs/configuration.md +60 -0
  5. package/docs/container-lifecycle.md +27 -15
  6. package/docs/extensions.md +36 -0
  7. package/docs/goals/rehearsal-bench/roadmap.md +1 -1
  8. package/docs/goals/release-gate/roadmap.md +8 -8
  9. package/docs/goals/whatsapp-bot-hardening/open-threads.md +18 -0
  10. package/docs/goals/whatsapp-bot-hardening/roadmap.md +1 -1
  11. package/docs/graceful-shutdown.md +21 -5
  12. package/docs/pending-verification.md +195 -4
  13. package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/decisions.md +1 -0
  14. package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/plan.md +2 -2
  15. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S1.md +56 -0
  16. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S2.md +51 -0
  17. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S3.md +60 -0
  18. package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S4.md +46 -0
  19. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S1.md +60 -0
  20. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S2.md +60 -0
  21. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S3.md +58 -0
  22. package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S4.md +58 -0
  23. package/docs/plans/archive/2026-09-22-p0-bugs-0922/decisions.md +8 -0
  24. package/docs/plans/archive/2026-09-22-p0-bugs-0922/plan.md +102 -0
  25. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S1.md +29 -0
  26. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S2.md +29 -0
  27. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S3.md +30 -0
  28. package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S4.md +32 -0
  29. package/docs/plans/bench-patterns-0915/briefs/S10.md +72 -0
  30. package/docs/plans/bench-patterns-0915/briefs/S11.md +95 -0
  31. package/docs/plans/bench-patterns-0915/briefs/S12.md +87 -0
  32. package/docs/plans/bench-patterns-0915/briefs/S9.md +81 -0
  33. package/docs/plans/bench-patterns-0915/decisions.md +7 -1
  34. package/docs/plans/bench-patterns-0915/plan.md +92 -8
  35. package/docs/plans/bench-patterns-0915/sessions/S10.md +40 -0
  36. package/docs/plans/bench-patterns-0915/sessions/S11.md +53 -0
  37. package/docs/plans/bench-patterns-0915/sessions/S12.md +44 -0
  38. package/docs/plans/bench-patterns-0915/sessions/S4.md +29 -0
  39. package/docs/plans/bench-patterns-0915/sessions/S5.md +30 -0
  40. package/docs/plans/bench-patterns-0915/sessions/S6.md +46 -0
  41. package/docs/plans/bench-patterns-0915/sessions/S7.md +30 -0
  42. package/docs/plans/bench-patterns-0915/sessions/S8.md +30 -0
  43. package/docs/plans/bench-patterns-0915/sessions/S9.md +2 -0
  44. package/docs/plans/flat-0916/briefs/S1.md +55 -0
  45. package/docs/plans/flat-0916/briefs/S2.md +55 -0
  46. package/docs/plans/flat-0916/decisions.md +8 -0
  47. package/docs/plans/flat-0916/plan.md +88 -0
  48. package/docs/plans/p0-plan-0922/briefs/S1.md +60 -0
  49. package/docs/plans/p0-plan-0922/briefs/S2.md +54 -0
  50. package/docs/plans/p0-plan-0922/briefs/S3.md +58 -0
  51. package/docs/plans/p0-plan-0922/briefs/S4.md +54 -0
  52. package/docs/plans/p0-plan-0922/briefs/S5.md +79 -0
  53. package/docs/plans/p0-plan-0922/briefs/S6.md +77 -0
  54. package/docs/plans/p0-plan-0922/briefs/S7.md +79 -0
  55. package/docs/plans/p0-plan-0922/briefs/S8.md +69 -0
  56. package/docs/plans/p0-plan-0922/decisions.md +9 -0
  57. package/docs/plans/p0-plan-0922/plan.md +215 -0
  58. package/docs/plans/p0-plan-0922/sessions/S1.md +22 -0
  59. package/docs/plans/p0-plan-0922/sessions/S2.md +22 -0
  60. package/docs/plans/p0-plan-0922/sessions/S3.md +22 -0
  61. package/docs/plans/p0-plan-0922/sessions/S4.md +22 -0
  62. package/docs/plans/p0-plan-0922/sessions/S5.md +44 -0
  63. package/docs/plans/p0-plan-0922/sessions/S6.md +60 -0
  64. package/docs/plans/p0-plan-0922/sessions/S7.md +69 -0
  65. package/docs/plans/sensitive-bugs-0922/briefs/S1.md +63 -0
  66. package/docs/plans/sensitive-bugs-0922/briefs/S2.md +62 -0
  67. package/docs/plans/sensitive-bugs-0922/briefs/S3.md +61 -0
  68. package/docs/plans/sensitive-bugs-0922/briefs/S4.md +61 -0
  69. package/docs/plans/sensitive-bugs-0922/briefs/S5.md +62 -0
  70. package/docs/plans/sensitive-bugs-0922/decisions.md +9 -0
  71. package/docs/plans/sensitive-bugs-0922/plan.md +147 -0
  72. package/docs/release-gate.md +1 -1
  73. package/docs/smoke-fixtures.md +9 -0
  74. package/examples/extensions/feed-watch/fixtures.ts +210 -0
  75. package/examples/extensions/feed-watch/index.ts +140 -9
  76. package/examples/extensions/office-365-mail/README.md +132 -0
  77. package/examples/extensions/office-365-mail/index.ts +170 -0
  78. package/examples/extensions/office-365-mail/lib/graph.ts +1031 -0
  79. package/examples/extensions/office-365-mail/scripts/login.ts +252 -0
  80. package/examples/extensions/office-365-mail/skill/SKILL.md +94 -0
  81. package/examples/profiles/football-reporter/AGENTS.md +15 -2
  82. package/examples/profiles/football-reporter/config.yaml +5 -1
  83. package/examples/profiles/tel-aviv-flat-search/AGENTS.md +5 -1
  84. package/package.json +10 -6
  85. package/resources/pi-extensions/subagent/index.ts +38 -42
  86. package/resources/pi-extensions/subagent/outcome.ts +28 -0
  87. package/src/adapters/whatsapp-thread.ts +12 -0
  88. package/src/agent/container-entry.ts +40 -7
  89. package/src/agent/container-env.ts +26 -0
  90. package/src/agent/container-error.ts +28 -0
  91. package/src/agent/container-runner.ts +50 -14
  92. package/src/audit/ledger-queries.ts +35 -3
  93. package/src/bridges/slack.ts +4 -1
  94. package/src/bridges/teams.ts +55 -15
  95. package/src/bridges/telegram.ts +3 -1
  96. package/src/cli/mercury.ts +46 -0
  97. package/src/cli/mrctl-http.ts +25 -0
  98. package/src/cli/mrctl.ts +13 -14
  99. package/src/config.ts +70 -34
  100. package/src/core/api.ts +51 -29
  101. package/src/core/boot-reconcile.ts +168 -0
  102. package/src/core/chat-run-journal.ts +383 -0
  103. package/src/core/debounce.ts +97 -1
  104. package/src/core/handler.ts +292 -44
  105. package/src/core/instance-lock.ts +159 -0
  106. package/src/core/operator-alerts.ts +80 -10
  107. package/src/core/pending-ingress.ts +298 -0
  108. package/src/core/routes/dashboard.ts +28 -6
  109. package/src/core/routes/media.ts +22 -0
  110. package/src/core/routes/shadow.ts +28 -3
  111. package/src/core/runtime.ts +139 -4
  112. package/src/core/simulate-ingress.ts +5 -2
  113. package/src/core/space-queue.ts +38 -9
  114. package/src/core/storage-cleanup.ts +16 -8
  115. package/src/core/system-messages.ts +50 -0
  116. package/src/core/task-scheduler.ts +150 -14
  117. package/src/dashboard/index.html +2 -0
  118. package/src/main.ts +121 -2
  119. package/src/ops/shadow-service.ts +113 -1
  120. package/src/ops/shadow-snapshot.ts +126 -0
  121. package/src/preflight/checks/roundtrip.ts +50 -39
  122. package/src/smoke/cli.ts +24 -4
  123. package/src/storage/db.ts +613 -89
  124. package/src/types.ts +20 -3
  125. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S1.md +0 -0
  126. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S2.md +0 -0
  127. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S3.md +0 -0
  128. /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S4.md +0 -0
@@ -40,6 +40,7 @@ import {
40
40
  formatSystemMessage,
41
41
  type MessageLocale,
42
42
  resolveLocale,
43
+ type SystemMessageKey,
43
44
  } from "./system-messages.js";
44
45
 
45
46
  /**
@@ -94,17 +95,29 @@ export interface MissedRunsAlert {
94
95
  name: string | null;
95
96
  /** Who created the task — the alert's first-choice recipient. */
96
97
  createdBy: string;
97
- /** How many occurrences were skipped. Always >= 1. */
98
+ /**
99
+ * How many occurrences the host slept through. May be 0 when the report is
100
+ * only about an interrupted run; `missed + interrupted` is always >= 1.
101
+ */
98
102
  missed: number;
103
+ /**
104
+ * 1 when the occurrence at `oldest` was not missed but *interrupted*: it
105
+ * started and a restart, crash or host death cut it off. Not a failure — the
106
+ * report says "interrupted by a restart" rather than "missed" for it.
107
+ */
108
+ interrupted: 0 | 1;
99
109
  /**
100
110
  * True when the count is a floor rather than a total — the scheduler's walk
101
111
  * hit its scan limit. Rendered as "at least N" so a lower bound is never
102
112
  * presented as a fact.
103
113
  */
104
114
  capped: boolean;
105
- /** Epoch ms of the oldest missed occurrence. */
115
+ /**
116
+ * Epoch ms of the oldest occurrence that did not run to completion — the
117
+ * interrupted one when `interrupted` is 1, else the oldest missed one.
118
+ */
106
119
  oldest: number;
107
- /** Epoch ms of the newest missed occurrence. */
120
+ /** Epoch ms of the newest occurrence that did not run to completion. */
108
121
  newest: number;
109
122
  /** The task's timezone, so the alert names times the way the task is written. */
110
123
  timezone: string | null;
@@ -512,6 +525,58 @@ export function createOperatorAlerter(
512
525
  newest: formatOccurrence(alert.newest, alert.timezone),
513
526
  });
514
527
 
528
+ /**
529
+ * Which sentence a report gets. Three shapes: only missed (the host slept
530
+ * through it), only interrupted (a restart cut the run off — not a failure,
531
+ * and not "missed"), or both (cut off, then down for more occurrences).
532
+ * Each in a long form and a list line, each by policy.
533
+ */
534
+ const missedRunKey = (
535
+ alert: MissedRunsAlert,
536
+ form: "single" | "line",
537
+ ): SystemMessageKey => {
538
+ const caughtUp = alert.policy === "caught-up";
539
+ const shape =
540
+ alert.interrupted === 0
541
+ ? "missed"
542
+ : alert.missed === 0
543
+ ? "interrupted"
544
+ : "interrupted_then_missed";
545
+ const keys: Record<
546
+ typeof shape,
547
+ Record<typeof form, [SystemMessageKey, SystemMessageKey]>
548
+ > = {
549
+ missed: {
550
+ single: ["alert_missed_runs_skipped", "alert_missed_runs_caught_up"],
551
+ line: [
552
+ "alert_missed_runs_line_skipped",
553
+ "alert_missed_runs_line_caught_up",
554
+ ],
555
+ },
556
+ interrupted: {
557
+ single: [
558
+ "alert_interrupted_run_skipped",
559
+ "alert_interrupted_run_caught_up",
560
+ ],
561
+ line: [
562
+ "alert_missed_runs_line_interrupted_skipped",
563
+ "alert_missed_runs_line_interrupted_caught_up",
564
+ ],
565
+ },
566
+ interrupted_then_missed: {
567
+ single: [
568
+ "alert_interrupted_then_missed_skipped",
569
+ "alert_interrupted_then_missed_caught_up",
570
+ ],
571
+ line: [
572
+ "alert_missed_runs_line_interrupted_then_missed_skipped",
573
+ "alert_missed_runs_line_interrupted_then_missed_caught_up",
574
+ ],
575
+ },
576
+ };
577
+ return keys[shape][form][caughtUp ? 1 : 0];
578
+ };
579
+
515
580
  /**
516
581
  * One message for everything this recipient lost. A single task keeps the
517
582
  * long form, which has room to explain the policy; several become a list,
@@ -527,9 +592,7 @@ export function createOperatorAlerter(
527
592
  if (reports.length === 1) {
528
593
  return formatSystemMessage(
529
594
  locale,
530
- first.policy === "caught-up"
531
- ? "alert_missed_runs_caught_up"
532
- : "alert_missed_runs_skipped",
595
+ missedRunKey(first, "single"),
533
596
  missedRunFields(first, locale),
534
597
  );
535
598
  }
@@ -537,9 +600,7 @@ export function createOperatorAlerter(
537
600
  .map((report) =>
538
601
  formatSystemMessage(
539
602
  locale,
540
- report.policy === "caught-up"
541
- ? "alert_missed_runs_line_caught_up"
542
- : "alert_missed_runs_line_skipped",
603
+ missedRunKey(report, "line"),
543
604
  missedRunFields(report, locale),
544
605
  ),
545
606
  )
@@ -809,13 +870,22 @@ export function createOperatorAlerter(
809
870
  for (const report of reports) {
810
871
  const recipient = sender ? resolveMissedRunRecipient(report) : null;
811
872
  if (!recipient) {
873
+ const what = [
874
+ report.interrupted ? "had a run interrupted by a restart" : "",
875
+ report.missed > 0
876
+ ? `missed ${report.capped ? "at least " : ""}${report.missed} scheduled run(s)`
877
+ : "",
878
+ ]
879
+ .filter(Boolean)
880
+ .join(" and ");
812
881
  logger.error(
813
- `Missed ${report.capped ? "at least " : ""}${report.missed} scheduled run(s) of task ${report.taskId} and could not tell anyone — ${sender ? "neither the task's creator nor its space has a conversation to deliver into" : "no message sender is bound yet"}. The only record is this log line.`,
882
+ `Task ${report.taskId} ${what} and could not tell anyone — ${sender ? "neither the task's creator nor its space has a conversation to deliver into" : "no message sender is bound yet"}. The only record is this log line.`,
814
883
  {
815
884
  taskId: report.taskId,
816
885
  spaceId: report.spaceId,
817
886
  createdBy: report.createdBy,
818
887
  missed: report.missed,
888
+ interrupted: report.interrupted,
819
889
  capped: report.capped,
820
890
  oldest: new Date(report.oldest).toISOString(),
821
891
  newest: new Date(report.newest).toISOString(),
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Debounced chat messages that survive a restart.
3
+ *
4
+ * `MessageDebouncer` holds inbound messages in memory until its idle timer
5
+ * fires, and the user message is first written to `messages` only when the
6
+ * run starts. A restart used to drop every held message with no trace at all:
7
+ * the sender believed the bot saw it, and the bot never did.
8
+ *
9
+ * Flushing them at shutdown cannot work — the queue is cancelled and the
10
+ * containers killed before shutdown hooks run, inside a 10 s budget — so they
11
+ * are **persisted** at shutdown (`pending_ingress`, one row per debounce key)
12
+ * and **replayed** by the next boot through the handler's live dispatch path.
13
+ *
14
+ * The boot side is the shape of every other kind of interrupted work (see
15
+ * `boot-reconcile.ts`): {@link pendingIngressReconciler} runs on the one boot
16
+ * pass, before any adapter, and drops what is too old or unreadable;
17
+ * {@link replayPendingIngress} runs after the adapters and hands each live row
18
+ * to its handler. The row stays in the table between the two, so a crash in
19
+ * that window replays it on the next boot instead of losing it.
20
+ */
21
+
22
+ import { logger } from "../logger.js";
23
+ import type { Db, PendingIngressRecord } from "../storage/db.js";
24
+ import type { IngressMessage } from "../types.js";
25
+ import type { BootReconciler, InterruptedWork } from "./boot-reconcile.js";
26
+
27
+ export const PENDING_INGRESS_KIND = "pending_ingress";
28
+
29
+ export type PendingReason =
30
+ /** Waiting in a debounce batch for its idle timer. */
31
+ | "batch"
32
+ /** Parked behind an in-flight run for the same key. */
33
+ | "queued-behind-run"
34
+ /** Reached the handler after shutdown had begun. */
35
+ | "arrived-during-shutdown";
36
+
37
+ const PENDING_REASONS: ReadonlySet<string> = new Set<PendingReason>([
38
+ "batch",
39
+ "queued-behind-run",
40
+ "arrived-during-shutdown",
41
+ ]);
42
+
43
+ export interface PendingIngressRow {
44
+ id: number;
45
+ platform: string;
46
+ /** For logs and operator queries; replay re-derives the key from the messages. */
47
+ debounceKey: string;
48
+ spaceId: string;
49
+ reason: PendingReason;
50
+ /** In arrival order, NOT merged. */
51
+ ingresses: IngressMessage[];
52
+ savedAt: number;
53
+ }
54
+
55
+ /** Saved longer ago than this at boot → dropped and logged, not replayed. */
56
+ export const PENDING_INGRESS_MAX_AGE_MS = 15 * 60_000; // owner decision 2026-09-22
57
+
58
+ export function encodePendingIngress(
59
+ row: Omit<PendingIngressRow, "id">,
60
+ ): Omit<PendingIngressRecord, "id"> {
61
+ return {
62
+ platform: row.platform,
63
+ debounceKey: row.debounceKey,
64
+ spaceId: row.spaceId,
65
+ reason: row.reason,
66
+ ingressesJson: JSON.stringify(row.ingresses),
67
+ savedAt: row.savedAt,
68
+ };
69
+ }
70
+
71
+ function isIngress(value: unknown): value is IngressMessage {
72
+ if (typeof value !== "object" || value === null) return false;
73
+ const m = value as Record<string, unknown>;
74
+ return (
75
+ typeof m.platform === "string" &&
76
+ typeof m.spaceId === "string" &&
77
+ typeof m.threadId === "string" &&
78
+ m.threadId.length > 0 &&
79
+ typeof m.conversationExternalId === "string" &&
80
+ typeof m.callerId === "string" &&
81
+ typeof m.text === "string" &&
82
+ typeof m.isDM === "boolean" &&
83
+ typeof m.isReplyToBot === "boolean" &&
84
+ Array.isArray(m.attachments)
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The stored row, or null when it cannot be replayed safely. Every message
90
+ * must carry its own thread (the reply target is read off the message, never
91
+ * off the row) and all of them must belong to the row's platform and space.
92
+ */
93
+ export function decodePendingIngress(
94
+ record: PendingIngressRecord,
95
+ ): PendingIngressRow | null {
96
+ if (!PENDING_REASONS.has(record.reason)) return null;
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(record.ingressesJson);
100
+ } catch {
101
+ return null;
102
+ }
103
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
104
+ if (!parsed.every(isIngress)) return null;
105
+ const ingresses = parsed as IngressMessage[];
106
+ if (
107
+ !ingresses.every(
108
+ (m) =>
109
+ m.platform === record.platform &&
110
+ m.spaceId === record.spaceId &&
111
+ m.threadId === ingresses[0].threadId &&
112
+ m.callerId === ingresses[0].callerId,
113
+ )
114
+ ) {
115
+ return null;
116
+ }
117
+ return {
118
+ id: record.id,
119
+ platform: record.platform,
120
+ debounceKey: record.debounceKey,
121
+ spaceId: record.spaceId,
122
+ reason: record.reason as PendingReason,
123
+ ingresses,
124
+ savedAt: record.savedAt,
125
+ };
126
+ }
127
+
128
+ export interface PendingIngressReplayer {
129
+ replay(row: PendingIngressRow): void;
130
+ }
131
+
132
+ /** The `detail` of a `pending_ingress` item — counts and keys, never text. */
133
+ export interface PendingIngressDetail {
134
+ platform: string;
135
+ key: string;
136
+ reason: PendingReason;
137
+ messages: number;
138
+ }
139
+
140
+ function preview(record: PendingIngressRecord): string {
141
+ return record.ingressesJson.slice(0, 80);
142
+ }
143
+
144
+ /**
145
+ * Boot pass: every row a previous process saved (`savedAt < bootAt`) is either
146
+ * dropped — unreadable (error), or saved more than
147
+ * {@link PENDING_INGRESS_MAX_AGE_MS} before this boot (warn: the chat has moved
148
+ * on and a late answer would be noise) — or returned as a live item. Live rows
149
+ * are left in the table; {@link replayPendingIngress} claims them.
150
+ */
151
+ export const pendingIngressReconciler: BootReconciler = {
152
+ name: PENDING_INGRESS_KIND,
153
+ reconcile({ db, bootAt }) {
154
+ const live: InterruptedWork[] = [];
155
+ let stale = 0;
156
+ let malformed = 0;
157
+ for (const record of db.listPendingIngress()) {
158
+ if (record.savedAt >= bootAt) continue;
159
+ const row = decodePendingIngress(record);
160
+ if (!row) {
161
+ if (db.claimPendingIngress(record.id)) {
162
+ malformed++;
163
+ logger.error("Boot: pending ingress row is malformed; dropped", {
164
+ id: record.id,
165
+ key: record.debounceKey,
166
+ reason: record.reason,
167
+ preview: preview(record),
168
+ });
169
+ }
170
+ continue;
171
+ }
172
+ const ageMs = bootAt - row.savedAt;
173
+ if (ageMs > PENDING_INGRESS_MAX_AGE_MS) {
174
+ if (db.claimPendingIngress(row.id)) {
175
+ stale++;
176
+ logger.warn("Boot: pending ingress too old to answer; dropped", {
177
+ key: row.debounceKey,
178
+ reason: row.reason,
179
+ count: row.ingresses.length,
180
+ ageMs,
181
+ textPreview: row.ingresses[0].text.slice(0, 80),
182
+ });
183
+ }
184
+ continue;
185
+ }
186
+ live.push({
187
+ kind: PENDING_INGRESS_KIND,
188
+ id: row.id,
189
+ spaceId: row.spaceId,
190
+ startedAt: row.savedAt,
191
+ detail: {
192
+ platform: row.platform,
193
+ key: row.debounceKey,
194
+ reason: row.reason,
195
+ messages: row.ingresses.length,
196
+ } satisfies PendingIngressDetail,
197
+ });
198
+ }
199
+ if (stale > 0 || malformed > 0) {
200
+ logger.info("Boot: pending ingress dropped", { stale, malformed });
201
+ }
202
+ return live;
203
+ },
204
+ };
205
+
206
+ export interface PendingIngressReplayCounts {
207
+ replayed: number;
208
+ /** No handler for the row's platform: kept, aged out by a later boot. */
209
+ unowned: number;
210
+ /** A shadow: nothing replayed, rows untouched. */
211
+ skipped: number;
212
+ /** Row gone or unreadable by the time of replay (claimed elsewhere). */
213
+ missing: number;
214
+ /** Claimed, and the handler threw: the one loss, logged in full. */
215
+ failed: number;
216
+ }
217
+
218
+ /**
219
+ * After the adapters: hand each live `pending_ingress` item back to the
220
+ * handler for its platform, oldest first. A row is dispatched only after
221
+ * `claimPendingIngress` returned true, so it is answered at most once.
222
+ *
223
+ * `shadow` is the literal `config.shadow`: a shadow replays nothing and leaves
224
+ * every row in place.
225
+ */
226
+ export function replayPendingIngress(opts: {
227
+ items: readonly InterruptedWork[];
228
+ db: Db;
229
+ handlers: ReadonlyMap<string, PendingIngressReplayer>;
230
+ shadow: boolean;
231
+ }): PendingIngressReplayCounts {
232
+ const { db, handlers, shadow } = opts;
233
+ const items = opts.items.filter((i) => i.kind === PENDING_INGRESS_KIND);
234
+ const counts: PendingIngressReplayCounts = {
235
+ replayed: 0,
236
+ unowned: 0,
237
+ skipped: 0,
238
+ missing: 0,
239
+ failed: 0,
240
+ };
241
+
242
+ if (shadow) {
243
+ counts.skipped = items.length;
244
+ logger.info("Boot: pending ingress", { ...counts, shadow: true });
245
+ return counts;
246
+ }
247
+
248
+ for (const item of items) {
249
+ const record = db.getPendingIngress(item.id);
250
+ const row = record ? decodePendingIngress(record) : null;
251
+ if (!row) {
252
+ counts.missing++;
253
+ logger.warn("Boot: pending ingress row vanished before replay", {
254
+ id: item.id,
255
+ spaceId: item.spaceId,
256
+ });
257
+ continue;
258
+ }
259
+
260
+ const handler = handlers.get(row.platform);
261
+ if (!handler) {
262
+ counts.unowned++;
263
+ logger.warn("Boot: pending ingress for an unconfigured platform; kept", {
264
+ platform: row.platform,
265
+ key: row.debounceKey,
266
+ count: row.ingresses.length,
267
+ });
268
+ continue;
269
+ }
270
+
271
+ if (!db.claimPendingIngress(row.id)) {
272
+ counts.missing++;
273
+ logger.info("Boot: pending ingress already claimed elsewhere", {
274
+ id: row.id,
275
+ key: row.debounceKey,
276
+ });
277
+ continue;
278
+ }
279
+ try {
280
+ handler.replay(row);
281
+ counts.replayed++;
282
+ } catch (err) {
283
+ // Claimed and not handed over: the one place a message can still be
284
+ // lost, so it is named in full.
285
+ counts.failed++;
286
+ logger.error("Boot: pending ingress replay threw; messages lost", {
287
+ key: row.debounceKey,
288
+ reason: row.reason,
289
+ count: row.ingresses.length,
290
+ textPreview: row.ingresses[0].text.slice(0, 80),
291
+ error: err instanceof Error ? err.message : String(err),
292
+ });
293
+ }
294
+ }
295
+
296
+ logger.info("Boot: pending ingress", counts);
297
+ return counts;
298
+ }
@@ -50,6 +50,16 @@ const SKIP_REASON_LABEL: Record<SkipReason, string> = {
50
50
  "no-index": "no index.ts",
51
51
  };
52
52
 
53
+ /**
54
+ * The two ledger states a run can be in without having an outcome. One
55
+ * rendering each, shared by the task list and the run history, so the two
56
+ * cannot drift into saying different things about the same row.
57
+ */
58
+ const RUNNING_BADGE =
59
+ '<span class="badge blue" title="In flight now.">running</span>';
60
+ const INTERRUPTED_BADGE =
61
+ '<span class="badge amber" title="The process stopped mid-run (restart, crash, or host death). Not a failure.">interrupted</span>';
62
+
53
63
  const VOICE_TRANSCRIBE_EXT = "voice-transcribe";
54
64
  const VT_KEY = {
55
65
  provider: `${VOICE_TRANSCRIBE_EXT}.provider`,
@@ -1786,12 +1796,18 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1786
1796
  // `failed` still comes from the snapshot, so the badge means the
1787
1797
  // same thing it did before the ledger existed. `quiet` is the new
1788
1798
  // answer — a run that succeeded and deliberately said nothing.
1799
+ // `running` / `interrupted` come from the newest row, which the
1800
+ // snapshot (last *completed* attempt) never reflects.
1789
1801
  const outcomeBadge =
1790
1802
  t.lastStatus === "error"
1791
1803
  ? ' <span class="badge">failed</span>'
1792
- : lastRun?.outcome === "no_update"
1793
- ? ' <span class="badge muted">quiet</span>'
1794
- : "";
1804
+ : lastRun?.status === "running"
1805
+ ? ` ${RUNNING_BADGE}`
1806
+ : lastRun?.status === "interrupted"
1807
+ ? ` ${INTERRUPTED_BADGE}`
1808
+ : lastRun?.outcome === "no_update"
1809
+ ? ' <span class="badge muted">quiet</span>'
1810
+ : "";
1795
1811
  return `
1796
1812
  <tr data-space-id="${escapeHtml(t.spaceId || "")}">
1797
1813
  <td class="mono">#${t.id}</td>
@@ -1901,6 +1917,8 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1901
1917
 
1902
1918
  const outcomeCell = (run: (typeof runs)[number]): string => {
1903
1919
  if (run.status === "error") return '<span class="badge">failed</span>';
1920
+ if (run.status === "running") return RUNNING_BADGE;
1921
+ if (run.status === "interrupted") return INTERRUPTED_BADGE;
1904
1922
  if (run.outcome === "no_update")
1905
1923
  return '<span class="badge muted">quiet</span>';
1906
1924
  if (run.outcome === "posted")
@@ -1913,9 +1931,13 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1913
1931
  ? runs
1914
1932
  .map((run) => {
1915
1933
  // A clock moved backwards mid-run makes this negative. Say
1916
- // nothing rather than report a negative duration as fact.
1917
- const ms = run.finishedAt - run.startedAt;
1918
- const duration = ms >= 0 ? `${Math.round(ms / 1000)}s` : "—";
1934
+ // nothing rather than report a negative duration as fact. No
1935
+ // finish time — still running, or interrupted by a hard kill —
1936
+ // has no duration either.
1937
+ const ms =
1938
+ run.finishedAt === null ? null : run.finishedAt - run.startedAt;
1939
+ const duration =
1940
+ ms !== null && ms >= 0 ? `${Math.round(ms / 1000)}s` : "—";
1919
1941
  return `
1920
1942
  <tr>
1921
1943
  <td class="muted">${formatRelativeTime(run.startedAt)}</td>
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Hono } from "hono";
5
5
  import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
6
+ import { getStorageInfo } from "./storage.js";
6
7
 
7
8
  export const media = new Hono<Env>();
8
9
 
@@ -50,6 +51,27 @@ media.post("/purge", async (c) => {
50
51
  });
51
52
  });
52
53
 
54
+ /**
55
+ * GET /media/storage — disk, database and per-space media usage, for `mrctl disk`.
56
+ *
57
+ * The caller-token route to what `/api/console/storage` serves the operator,
58
+ * byte for byte (same helper, same inputs). Gated on `media.purge` because
59
+ * the response lists EVERY space's inbox/outbox: it is a host-wide view, the
60
+ * one an operator reads before a purge, and `media.purge` is admin-only by
61
+ * default. Members could read it before only because every container held
62
+ * the operator secret.
63
+ */
64
+ media.get("/storage", async (c) => {
65
+ const denied = checkPerm(c, "media.purge");
66
+ if (denied) return denied;
67
+ const { config } = getApiCtx(c);
68
+ const info = await getStorageInfo({
69
+ spacesDir: config.spacesDir,
70
+ dbPath: config.dbPath,
71
+ });
72
+ return c.json(info);
73
+ });
74
+
53
75
  /** Remove all files inside a directory (non-recursive into subdirs). Returns count of removed entries. */
54
76
  async function purgeDir(dir: string): Promise<number> {
55
77
  let entries: string[];
@@ -53,12 +53,36 @@ import {
53
53
  /** The two members the route uses; a test hands in a fake with exactly these. */
54
54
  export type ShadowSimulateSeam = Pick<IngressSimulator, "check" | "simulate">;
55
55
 
56
+ /**
57
+ * `Bun.serve`'s default `idleTimeout`, in ms: a connection with no bytes in
58
+ * either direction for this long is closed by the server itself, with
59
+ * `[Bun.serve]: request timed out after 10 seconds` in the journal. None of
60
+ * the three `Bun.serve` sites (`main.ts` TCP, `main.ts` unix socket,
61
+ * `smoke/boot.ts`) sets the knob, so this is the number the route streams
62
+ * against. `tests/shadow-route.test.ts` pins it to the installed bun-types.
63
+ */
64
+ export const BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS = 10_000;
65
+
66
+ /**
67
+ * Default gap between `: keepalive` comments on the simulate stream. Well
68
+ * under the idle timeout, not just under it: the host runs on one thread,
69
+ * and a tick delayed by a blocking Docker or child-process call still has to
70
+ * land before Bun's clock runs out. The first "idle proxy" that closed this
71
+ * stream was `Bun.serve` — the default was 15 s against its 10 s.
72
+ */
73
+ export const DEFAULT_SHADOW_KEEPALIVE_MS = 4_000;
74
+
56
75
  export interface ShadowRouteOptions {
57
76
  /** Undefined when the simulator failed to construct at boot: every call is a 503. */
58
77
  simulator: ShadowSimulateSeam | undefined;
59
78
  /** `config.shadowApiSecret`. Undefined ⇒ every call is a 503. */
60
79
  secret: string | undefined;
61
- /** Seconds between `: keepalive` comments while a run is in flight. */
80
+ /**
81
+ * Milliseconds between `: keepalive` comments while a run is in flight.
82
+ * Default `DEFAULT_SHADOW_KEEPALIVE_MS`; must stay well under
83
+ * `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS` (the test pins the default at half
84
+ * or less) or the server closes the stream before the first comment.
85
+ */
62
86
  keepaliveMs?: number;
63
87
  }
64
88
 
@@ -319,7 +343,7 @@ async function parseSimulateRequest(
319
343
 
320
344
  export function createShadowRoute(opts: ShadowRouteOptions): Hono {
321
345
  const app = new Hono();
322
- const keepaliveMs = opts.keepaliveMs ?? 15_000;
346
+ const keepaliveMs = opts.keepaliveMs ?? DEFAULT_SHADOW_KEEPALIVE_MS;
323
347
 
324
348
  app.post("/simulate", async (c) => {
325
349
  // Auth first, before the body is read: the secret is the shadow's own,
@@ -432,7 +456,8 @@ export function createShadowRoute(opts: ShadowRouteOptions): Hono {
432
456
  return tail;
433
457
  };
434
458
  // Comments are SSE's idle signal; a container run is minutes and a
435
- // silent stream is what an idle proxy closes.
459
+ // silent stream is what an idle proxy closes — `Bun.serve` itself
460
+ // first, at `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS`.
436
461
  const keepalive = setInterval(() => {
437
462
  if (gone || stream.closed) return;
438
463
  void stream.write(": keepalive\n\n").catch(() => {