mercury-agent 0.23.2 → 0.24.1
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/container/Dockerfile +1 -1
- package/container/Dockerfile.base +1 -1
- package/container/agent-package.json +1 -1
- package/docs/configuration.md +60 -0
- package/docs/container-lifecycle.md +27 -15
- package/docs/extensions.md +36 -0
- package/docs/goals/rehearsal-bench/roadmap.md +1 -1
- package/docs/goals/release-gate/roadmap.md +8 -8
- package/docs/goals/whatsapp-bot-hardening/open-threads.md +18 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +1 -1
- package/docs/graceful-shutdown.md +21 -5
- package/docs/pending-verification.md +195 -4
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/decisions.md +1 -0
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/plan.md +2 -2
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S1.md +56 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S2.md +51 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S3.md +60 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S4.md +46 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S1.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S2.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S3.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S4.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/decisions.md +8 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/plan.md +102 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S1.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S2.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S3.md +30 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S4.md +32 -0
- package/docs/plans/bench-patterns-0915/briefs/S10.md +72 -0
- package/docs/plans/bench-patterns-0915/briefs/S11.md +95 -0
- package/docs/plans/bench-patterns-0915/briefs/S12.md +87 -0
- package/docs/plans/bench-patterns-0915/briefs/S9.md +81 -0
- package/docs/plans/bench-patterns-0915/decisions.md +7 -1
- package/docs/plans/bench-patterns-0915/plan.md +92 -8
- package/docs/plans/bench-patterns-0915/sessions/S10.md +40 -0
- package/docs/plans/bench-patterns-0915/sessions/S11.md +53 -0
- package/docs/plans/bench-patterns-0915/sessions/S12.md +44 -0
- package/docs/plans/bench-patterns-0915/sessions/S4.md +29 -0
- package/docs/plans/bench-patterns-0915/sessions/S5.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S6.md +46 -0
- package/docs/plans/bench-patterns-0915/sessions/S7.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S8.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S9.md +2 -0
- package/docs/plans/flat-0916/briefs/S1.md +55 -0
- package/docs/plans/flat-0916/briefs/S2.md +55 -0
- package/docs/plans/flat-0916/decisions.md +8 -0
- package/docs/plans/flat-0916/plan.md +88 -0
- package/docs/plans/p0-plan-0922/briefs/S1.md +60 -0
- package/docs/plans/p0-plan-0922/briefs/S2.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S3.md +58 -0
- package/docs/plans/p0-plan-0922/briefs/S4.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S5.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S6.md +77 -0
- package/docs/plans/p0-plan-0922/briefs/S7.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S8.md +69 -0
- package/docs/plans/p0-plan-0922/decisions.md +9 -0
- package/docs/plans/p0-plan-0922/plan.md +215 -0
- package/docs/plans/p0-plan-0922/sessions/S1.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S2.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S3.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S4.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S5.md +44 -0
- package/docs/plans/p0-plan-0922/sessions/S6.md +60 -0
- package/docs/plans/p0-plan-0922/sessions/S7.md +69 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S1.md +63 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S2.md +62 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S3.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S4.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S5.md +62 -0
- package/docs/plans/sensitive-bugs-0922/decisions.md +9 -0
- package/docs/plans/sensitive-bugs-0922/plan.md +147 -0
- package/docs/release-gate.md +1 -1
- package/docs/smoke-fixtures.md +9 -0
- package/examples/extensions/feed-watch/fixtures.ts +210 -0
- package/examples/extensions/feed-watch/index.ts +140 -9
- package/examples/extensions/office-365-mail/README.md +132 -0
- package/examples/extensions/office-365-mail/index.ts +170 -0
- package/examples/extensions/office-365-mail/lib/graph.ts +1031 -0
- package/examples/extensions/office-365-mail/scripts/login.ts +252 -0
- package/examples/extensions/office-365-mail/skill/SKILL.md +94 -0
- package/examples/profiles/football-reporter/AGENTS.md +15 -2
- package/examples/profiles/football-reporter/config.yaml +5 -1
- package/examples/profiles/tel-aviv-flat-search/AGENTS.md +5 -1
- package/package.json +16 -12
- package/resources/pi-extensions/subagent/index.ts +38 -42
- package/resources/pi-extensions/subagent/outcome.ts +28 -0
- package/src/adapters/whatsapp-thread.ts +12 -0
- package/src/agent/container-entry.ts +40 -7
- package/src/agent/container-env.ts +26 -0
- package/src/agent/container-error.ts +28 -0
- package/src/agent/container-runner.ts +50 -14
- package/src/audit/ledger-queries.ts +35 -3
- package/src/bridges/slack.ts +4 -1
- package/src/bridges/teams.ts +55 -15
- package/src/bridges/telegram.ts +3 -1
- package/src/cli/mercury.ts +46 -0
- package/src/cli/mrctl-http.ts +25 -0
- package/src/cli/mrctl.ts +13 -14
- package/src/config.ts +70 -34
- package/src/core/api.ts +51 -29
- package/src/core/boot-reconcile.ts +168 -0
- package/src/core/chat-run-journal.ts +383 -0
- package/src/core/debounce.ts +97 -1
- package/src/core/handler.ts +292 -44
- package/src/core/instance-lock.ts +159 -0
- package/src/core/operator-alerts.ts +80 -10
- package/src/core/pending-ingress.ts +298 -0
- package/src/core/routes/dashboard.ts +28 -6
- package/src/core/routes/media.ts +22 -0
- package/src/core/routes/shadow.ts +28 -3
- package/src/core/runtime.ts +139 -4
- package/src/core/simulate-ingress.ts +5 -2
- package/src/core/space-queue.ts +38 -9
- package/src/core/storage-cleanup.ts +16 -8
- package/src/core/system-messages.ts +50 -0
- package/src/core/task-scheduler.ts +150 -14
- package/src/dashboard/index.html +2 -0
- package/src/main.ts +121 -2
- package/src/ops/shadow-service.ts +113 -1
- package/src/ops/shadow-snapshot.ts +126 -0
- package/src/preflight/checks/roundtrip.ts +50 -39
- package/src/smoke/cli.ts +24 -4
- package/src/storage/db.ts +621 -90
- package/src/types.ts +20 -3
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S1.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S2.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S3.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S4.md +0 -0
package/container/Dockerfile
CHANGED
|
@@ -60,7 +60,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
60
60
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
61
61
|
|
|
62
62
|
# Install CLIs
|
|
63
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
63
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.87.1
|
|
64
64
|
|
|
65
65
|
WORKDIR /app
|
|
66
66
|
|
|
@@ -42,7 +42,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
|
|
|
42
42
|
ENV CHROMIUM_FLAGS="--no-sandbox"
|
|
43
43
|
|
|
44
44
|
# Install CLIs
|
|
45
|
-
RUN bun add -g @earendil-works/pi-coding-agent@~0.
|
|
45
|
+
RUN bun add -g @earendil-works/pi-coding-agent@~0.87.1
|
|
46
46
|
|
|
47
47
|
WORKDIR /app
|
|
48
48
|
|
package/docs/configuration.md
CHANGED
|
@@ -105,6 +105,41 @@ Restarting *before* the make-up run lands drops it, the same way a restart drops
|
|
|
105
105
|
a pending retry: the boot pass leaves `next_run_at` off the cron grid, and the
|
|
106
106
|
next boot realigns normally.
|
|
107
107
|
|
|
108
|
+
### Interrupted is not missed
|
|
109
|
+
|
|
110
|
+
A run that **started** and was cut off — a restart that killed its container, a
|
|
111
|
+
crash, the OOM killer, a host or WSL death — is reported as *interrupted*, not
|
|
112
|
+
missed. Every scheduled run writes its ledger row (`task_runs`) when it starts;
|
|
113
|
+
on the next boot, any row still `running` is closed as `interrupted`, and the
|
|
114
|
+
journal names it:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
INFO Reconciled interrupted work kind=task_run id=1187 taskId=24
|
|
118
|
+
startedAt=2026-09-24T06:00:02Z ageMs=412000
|
|
119
|
+
INFO Boot reconciliation finished interrupted={"task_run":1}
|
|
120
|
+
WARN A scheduled run was interrupted by a restart taskId=24 missed=0
|
|
121
|
+
interrupted=1 policy=caught-up
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The creator's report says "was interrupted by a restart at 09:00" instead of
|
|
125
|
+
"missed". `interrupted` is not a failure anywhere: no "task failed" notice, no
|
|
126
|
+
retry, no heartbeat, and the dashboard, `mrctl tasks runs` and the audit queries
|
|
127
|
+
count it on its own, never as `error`.
|
|
128
|
+
|
|
129
|
+
What happens next is the policy above, unchanged: a `catch_up: once` task makes
|
|
130
|
+
the run up (inside the window), a `skip` task does not, and an `at` task runs
|
|
131
|
+
again on boot.
|
|
132
|
+
|
|
133
|
+
**A graceful restart now behaves like a hard kill.** Before, a run cut off by
|
|
134
|
+
`systemctl restart` / `~/sync-mercury.sh` was recorded as `error`, could post
|
|
135
|
+
"task failed", and wrote a retry time that hid the slot from the boot pass — so
|
|
136
|
+
a `catch_up: once` task lost its make-up run on exactly the restart it needed it
|
|
137
|
+
for. Now the scheduler, once it is stopping, records such a run `interrupted`
|
|
138
|
+
and leaves `next_run_at` on the slot, and the next boot applies the policy.
|
|
139
|
+
|
|
140
|
+
The boot pass trusts that nothing started before this process can still be
|
|
141
|
+
running, which holds with one live instance per data dir.
|
|
142
|
+
|
|
108
143
|
## Heartbeat (`MERCURY_HEARTBEAT_URL`, `MERCURY_HEARTBEAT_TASKS`)
|
|
109
144
|
|
|
110
145
|
Nothing on the box can alert while the box is off. Twice in six days the box
|
|
@@ -322,6 +357,31 @@ shared folder) — SQLite WAL locking is unreliable there.
|
|
|
322
357
|
The database itself refuses to open when `quick_check` fails — loudly, with a
|
|
323
358
|
typed error, never auto-repaired. The fix is `mercury restore`.
|
|
324
359
|
|
|
360
|
+
## One process per data dir (`mercury.instance.lock`)
|
|
361
|
+
|
|
362
|
+
A Mercury process takes an exclusive lock on **`<dataDir>/mercury.instance.lock`**
|
|
363
|
+
before it binds the port, opens the database or touches Docker, and holds it
|
|
364
|
+
for its whole life. A second process on the same data dir — on any port —
|
|
365
|
+
**refuses to start and exits 78** (`EX_CONFIG`), logging
|
|
366
|
+
`Another Mercury process holds this data dir; refusing to start` with the
|
|
367
|
+
holder from **`<dataDir>/mercury.instance.json`** (`{pid, startedAt, port,
|
|
368
|
+
version}`, informational only: the lock, not that file, decides). Any other
|
|
369
|
+
failure to take the lock — the data dir cannot be created, a read-only
|
|
370
|
+
filesystem — also refuses. Under systemd `Restart=` that is one refusal line
|
|
371
|
+
every few seconds, which is meant to be loud.
|
|
372
|
+
|
|
373
|
+
The lock is an SQLite `locking_mode = EXCLUSIVE` connection on its own file,
|
|
374
|
+
so the kernel drops it when the process dies however it dies — there is no
|
|
375
|
+
stale lock to clean up after a crash. It is a separate file because the main
|
|
376
|
+
database runs WAL with outside readers that an exclusive lock would shut out.
|
|
377
|
+
This is what makes the boot reconciliation honest: "open at boot" means
|
|
378
|
+
"orphaned" only when no other process shares the data dir. A shadow's data dir
|
|
379
|
+
is inside `~/mercury-shadow/`, so it takes its own lock beside the live bot.
|
|
380
|
+
|
|
381
|
+
Caveat: the lock relies on working POSIX/Windows file locks. Some network
|
|
382
|
+
filesystems (NFS without `lockd`, some SMB mounts) do not provide them; keep
|
|
383
|
+
the data dir on a local disk.
|
|
384
|
+
|
|
325
385
|
## Shadow mode (`MERCURY_SHADOW`)
|
|
326
386
|
|
|
327
387
|
**`MERCURY_SHADOW`** (default off; accepts only `1` or `true`,
|
|
@@ -307,21 +307,33 @@ never agree. The check therefore picks its auth mode from the config
|
|
|
307
307
|
|
|
308
308
|
| `callerAuth` | When | What the pass proves |
|
|
309
309
|
|---|---|---|
|
|
310
|
-
| `token` | `MERCURY_CALLER_TOKEN_KEY` (or `caller_token_key`) is set | the whole production shape — transport, `API_SECRET`,
|
|
311
|
-
| `headers` | no key configured (the shipped default) | transport
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
`
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
310
|
+
| `token` | `MERCURY_CALLER_TOKEN_KEY` (or `caller_token_key`) is set | the whole production shape — transport, the minted caller token **alone** (no `API_SECRET`, exactly what `reply()` hands a turn), and the identity the host hands back |
|
|
311
|
+
| `headers` | no key configured (the shipped default) | transport and the **operator** path — `API_SECRET` plus the identity headers. The container credential is **not** exercised, and the pass title says so |
|
|
312
|
+
|
|
313
|
+
The host API has two principals, one credential each (since 2026-09-23,
|
|
314
|
+
`require-caller-token-for-container-originated-requests`):
|
|
315
|
+
|
|
316
|
+
| Principal | Credential | Identity from | Who holds it |
|
|
317
|
+
|---|---|---|---|
|
|
318
|
+
| container | `x-mercury-token` (per turn, bound to caller + space) | the token — headers are ignored | every agent container (`CALLER_TOKEN`); the probe in `token` mode |
|
|
319
|
+
| operator | `Authorization: Bearer <API_SECRET>` | `x-mercury-caller` / `x-mercury-space` | the host: `.env`, runbook `curl`s, the dashboard, the console; the probe in `headers` mode |
|
|
320
|
+
|
|
321
|
+
A container is never handed `API_SECRET` — `buildAgentCreateArgs` drops any
|
|
322
|
+
`-e` pair whose value contains a host secret and logs the key at error — so
|
|
323
|
+
`/api` header identity, `/api/console/*`, `/dashboard/*`, `/chat` and
|
|
324
|
+
`/pre-build-ext-image` are all out of its reach. On `/api` the token header's
|
|
325
|
+
*presence* picks the branch: a present token is verified or refused (401),
|
|
326
|
+
never a fall-through to the secret. In `headers` mode the probe omits
|
|
327
|
+
`CALLER_TOKEN` and presents the secret, and it is exempt **by its principal**:
|
|
328
|
+
it runs host-side from the `.env` the service reads, as every operator call
|
|
329
|
+
does. `authorizeApiSecret` (`src/core/auth.ts`) still fails **closed** on that
|
|
330
|
+
path — 503 when the service has no secret configured, 401 on a missing or
|
|
331
|
+
mismatched one — before any identity header is read. Setting the key is still
|
|
332
|
+
worth doing, because it upgrades the check from `headers` to `token` and is
|
|
333
|
+
required by anything that mints in one process and verifies in another; the
|
|
334
|
+
real agent containers never needed it, since the service mints and verifies
|
|
335
|
+
their tokens in one process. Before 2026-09-06 the absent key was a permanent
|
|
336
|
+
blocker (`401 Invalid or expired caller token` on
|
|
325
337
|
every run, with a remedy that sent the operator to compare a `.env` with
|
|
326
338
|
itself) —
|
|
327
339
|
`docs/debug/moderate/2026-09-06-preflight-roundtrip-cannot-pass-without-configured-caller-token-key.md`.
|
package/docs/extensions.md
CHANGED
|
@@ -560,6 +560,42 @@ Slice 1 is invoices. Expenses are deferred: `POST /expenses` requires an
|
|
|
560
560
|
`accountingClassification` whose lookup endpoint is absent from the published
|
|
561
561
|
spec, and an uploaded file becomes a draft with no API to approve it.
|
|
562
562
|
|
|
563
|
+
## Office 365 Mail (`office-365-mail`)
|
|
564
|
+
|
|
565
|
+
`examples/extensions/office-365-mail/` reads and sends a Microsoft 365 /
|
|
566
|
+
Outlook mailbox over Microsoft Graph. Third `mercury.capability()` adopter, and
|
|
567
|
+
the first whose credential **rotates on every use**: Microsoft's token endpoint
|
|
568
|
+
returns a new refresh token with each exchange, so the `.env` value is only a
|
|
569
|
+
seed and the newest token lives in extension state under a fingerprint of the
|
|
570
|
+
seed it descends from (a changed seed wins; a failed exchange never deletes
|
|
571
|
+
the stored token). No CLI, no runtime dependencies — plain `fetch`.
|
|
572
|
+
|
|
573
|
+
```bash
|
|
574
|
+
mercury add ./examples/extensions/office-365-mail
|
|
575
|
+
bun .mercury/extensions/office-365-mail/scripts/login.ts --client-id <id> # device-code login, prints .env lines
|
|
576
|
+
MERCURY_M365_CLIENT_ID=... # hostOnly — gates loading (never rotates)
|
|
577
|
+
MERCURY_M365_TENANT=organizations # hostOnly — or a tenant id / domain
|
|
578
|
+
MERCURY_M365_REFRESH_TOKEN=... # hostOnly — the seed; may be removed after the first rotation
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
The agent calls it with `mrctl capability office-365-mail <action> '<json>'`
|
|
582
|
+
— the same ten verbs as yahoo-mail with `id` in place of `uid`:
|
|
583
|
+
`list-folders`, `list-inbox`, `search` (KQL), `read`, `move`, `delete` (a move
|
|
584
|
+
to `deleteditems`, never Graph's DELETE), `mark-read`, `mark-unread`, `send`,
|
|
585
|
+
`reply`. `move` and `delete` return the message's **new** id.
|
|
586
|
+
|
|
587
|
+
Two things the broker owns rather than the skill:
|
|
588
|
+
|
|
589
|
+
- **One exchange path.** `getAccessToken` is single-flight; the verbs, the
|
|
590
|
+
dashboard `statusCheck` and the daily `healthCheck` (a forced exchange) all
|
|
591
|
+
go through it, so the token store has exactly one writer.
|
|
592
|
+
- **Three-way failure classification.** `invalid_grant` /
|
|
593
|
+
`interaction_required` are `expired-or-revoked` (recorded, shown as
|
|
594
|
+
needs-reauth, returned to the model as `401` with `reauth: true`); 408/429/5xx
|
|
595
|
+
are `transient`; any other rejection — an app-registration problem — is
|
|
596
|
+
`unknown`, which alerts once and never says "re-login" for something a login
|
|
597
|
+
cannot fix.
|
|
598
|
+
|
|
563
599
|
## Examples
|
|
564
600
|
|
|
565
601
|
See [`examples/extensions/`](../examples/extensions/) for complete, working extensions ranging from minimal (charts — CLI + skill) to full-featured (napkin — hooks, jobs, config, widgets, KB distillation).
|
|
@@ -53,7 +53,7 @@ matrix in §2 or edits it; `ops-preflight` refuses the exact command of
|
|
|
53
53
|
| M1.3b | **Shadow admin list is chosen, never inherited** (added 2026-09-13, D-015, from bug `shadow-env-keys-does-not-override-mercury-admins`): `shadow snapshot --admins <id,…>` (default empty) is what `renderShadowEnv` writes as `MERCURY_ADMINS`; `SNAPSHOT.json.shadow.adminCount` records the count, `snapshot`/`up`/`status` print it (never the ids); `up` refuses a snapshot whose manifest records no count. Landed with the shadow-up hardening track (B1 of the 2026-09-12 bench-and-gate round), which also closed: the live project read from the unit's `WorkingDirectory` / `SNAPSHOT.json` (never cwd, disagreement refuses), the cron gate's third state (unknown denies, none allows), `mercury run` on `process.execPath` + the unit `PATH` composed from the live unit / `--path` (never the shell's), and the CLI tests' scoped user bus | shadow-admins | M1.3 | **done** 2026-09-13 — post-mortems under `docs/debug/{moderate,minor}/2026-09-1{2,3}-shadow-*` and `…-mercury-run-spawns-a-bare-bun-…` |
|
|
54
54
|
| M1.3c | **Shadow-own model credential** (added 2026-09-15, from bug `shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token`): the snapshot stops copying `global/auth.json`; `up` points `MERCURY_AUTH_PATH` at a shadow-only file the owner logs in to once (`mercury auth login` against the shadow root), so the shadow never holds the live refresh token — neither dies when the other rotates. D-016's principle ("a shadow answers to no live credential") extended from the API secrets to the model credential. The `Bun.serve` 10 s stream death on `/api/shadow/simulate` (bug `shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout`) — the other reason a bench run needs a person watching — is **not** this story's: it is fixed directly as plan `bugs-0915` S1 (corrected 2026-09-15), and the bug's shape (a) mitigation (`up` re-copies the live credential, `--dry-run` gates on it) ships as `bugs-0915` S4; this story owns shape (c) | shadow-own-credential | M1.3 | backlog |
|
|
55
55
|
| M1.6 | **Snapshot across hosts** (added 2026-09-03, D-013): `mercury shadow snapshot --out <file>` packs a snapshot as one verified archive (the `VACUUM INTO` db, `spaces/ global/ extensions/`, `SNAPSHOT.json`; never `whatsapp-auth/`, never `.env` values); `mercury shadow restore <file>` unpacks it under `~/mercury-shadow/<ts>/` on another host with the shadow `.env` + `mercury.yaml` generated there, ownership normalised to the running user (the Windows uid 197609 trap), and `MERCURY_SHADOW=1` written — a restored copy **cannot** start live without `mercury shadow promote`, which refuses while any other host is known to hold the WhatsApp session. Replaces tagula's `pack.sh`/`pull.sh` (torn WAL, exit 0, 2026-09-01) | snapshot-across-hosts | M1.2, M1.3 | backlog |
|
|
56
|
-
| M1.7 | **Synthetic feed input** (added 2026-09-15): in shadow mode feed-watch's `fetchImpl` (`examples/extensions/feed-watch/index.ts:183`) serves `~/mercury-shadow/current/fixtures/feeds/<source-key>` when the file exists and falls through to the network otherwise — no `http://` sources, so `config.ts:252`'s https-only rule stays; and `shadow snapshot --reset-feed-state` drops feed-watch's `seen:`/`buffer:`/`pending:` rows for a space so the first poll treats the fixture as new (the copied `seen` state is why a shadow at debug level found 0 exclude-term drops on 2026-09-15). A fixture is a plain RSS/JSON/HTML file; a `dead` marker makes the source fail, for the failure-routing lines | shadow-feed-fixtures | M1.2 |
|
|
56
|
+
| M1.7 | **Synthetic feed input** (added 2026-09-15; **shipped 2026-09-24** as `759d04e`, `docs/archive/rehearsal/2026-09-24-shadow-feed-fixtures.md` — as built, fixtures live under the shadow's `<dataDir>/fixtures/feeds/<encoded target key>`, not `current/`; `--reset-feed-state` was dropped for a per-process `{{nonce}}` token, so no state is deleted; the original text follows): in shadow mode feed-watch's `fetchImpl` (`examples/extensions/feed-watch/index.ts:183`) serves `~/mercury-shadow/current/fixtures/feeds/<source-key>` when the file exists and falls through to the network otherwise — no `http://` sources, so `config.ts:252`'s https-only rule stays; and `shadow snapshot --reset-feed-state` drops feed-watch's `seen:`/`buffer:`/`pending:` rows for a space so the first poll treats the fixture as new (the copied `seen` state is why a shadow at debug level found 0 exclude-term drops on 2026-09-15). A fixture is a plain RSS/JSON/HTML file; a `dead` marker makes the source fail, for the failure-routing lines | shadow-feed-fixtures | M1.2 | done |
|
|
57
57
|
|
|
58
58
|
**Checkpoint:** from a fresh snapshot, `space-profile apply --project
|
|
59
59
|
~/mercury-shadow/current --profile examples/profiles/football-reporter`
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
| R0.2 | **The build-integrity gate moves into this repo.** `agent-image.yml` in tagula-agents verifies Mercury's artifact — every `COPY` source present in the tarball, `bun build container-entry.ts` resolves inside the image — and that check is what would have caught 0.17.0. It belongs in `release.yml` (or `bun run check`) so every consumer building the image gets it, not only this one fleet. The fleet keeps its copy or drops it; either is fine once this exists | build-integrity-in-mercury | — | **landed 2026-09-06** — split by what it needs: `scripts/check-image-sources.ts` runs in `bun run check` with no Docker and proves the source contract (every COPY source exists, is inside the npm `files` allowlist, and every local module reachable from a copied `.ts` is itself copied — verified to reproduce the 0.17.0 miss by deleting the `sanitize-text.ts` COPY); `smoke:image` gained the artifact half from `agent-image.yml` — toolchains, the pi binary, the version label, and `bun build` resolving `container-entry.ts` and `mrctl.ts` inside the image |
|
|
37
37
|
| R0.3 | **Mercury publishes its own image.** A job in `release.yml`, after `publish-npm`, that installs the published tarball, builds, verifies (R0.2) and pushes `:<version>` + `:latest` with the R0.1 labels. Needs `packages: write`, which no job has today. **G-007 is decided: publish yes, visibility private for now** (2026-09-07), so this tidies the internal path; the third-party install path stays closed until visibility is revisited. Makes `agent-image.yml`'s *version-linking* half redundant; its nightly updater, idle check, snapshot, rollback and stagger stay where they are. **Move that workflow rather than rewriting it** — it already builds from the published tarball, which is the shape wanted here — and carry three traps across with it, all read out of it on 2026-09-06: (i) it derives the image name from `GITHUB_REPOSITORY`, which under this repo yields a different path than the fleet's, so set the name explicitly instead of inheriting it; (ii) a GHCR package takes the repo's visibility on first push, so a *public* answer to G-007 needs the package flipped by hand after the first run — it cannot be expressed in the YAML; (iii) the job runs **after** `publish-npm`, so a failed image push leaves npm ahead of the image, which is the 0.17.0 ordering inverted — keep boxes following the image (R0.4/R0.5) and make the job required so the gap is loud rather than latent; **(iv) found by the first real run (v0.20.0, 2026-09-07)**: the existing `mercury-agent` package is linked to `Avishai-Tsabari/ai-assistant` — the old fork — and GHCR grants a workflow's `GITHUB_TOKEN` write only on packages connected to *its* repo, so the push failed `permission_denied: write_package` after build and `smoke:image` had both passed. Fix is one-time and UI-only: package settings → *Manage Actions access* → add `Avishai-Tsabari/mercury` with **Write**. Hazard (iii) then fired exactly as written — npm 0.20.0 published, no image — and was harmless for the reason given | mercury-publishes-image | R0.1, R0.2 | **done 2026-09-07** — v0.20.0 was the first release to publish its own image end to end (attempt 2, after the owner granted the repo write on the package); `:0.20.0` and `:latest` share one digest. Was: **landed 2026-09-06 (Mercury side)** — `release.yml` gained a `publish-image` job with `packages: write`: builds from the published tarball, verifies with `smoke:image`, pushes `:<version>` and (stable only) `:latest`, image name set explicitly so it is not derived from `GITHUB_REPOSITORY`. Inert until the next `v*` tag. **One thing it cannot do itself**: retire `agent-image.yml` in tagula-agents — the owner's call. The package stays private (G-007), which is what it inherits by default, so no visibility step is pending |
|
|
38
38
|
| R0.4 | **A floating tag stops meaning "frozen at first pull".** `ensureImage()` returns the moment `docker image inspect` succeeds, so the default `:latest` is never refreshed and a host upgraded by npm keeps yesterday's container silently — the trap the owner hit on 2026-09-05. Either pin the default to `:${pkg.version}` (safe **only** after R0.3, or every fresh install fails on a tag nobody published) or re-pull a floating tag on a version change. Either way boot logs the resolved image ref, digest and version label so drift is visible without asking. Filed as `docs/bugs/ensure-image-never-refreshes-a-floating-tag.md`; take `docs/bugs/health-reports-stale-env-version.md` with it — `/health` reports `MERCURY_VERSION` from the environment and never the installed `package.json`, so the host half of "what is actually running here" lies for the same reason the image half does, and fixing one without the other leaves the console's version badge still wrong | image-refresh-and-visibility | R0.3 | **done 2026-09-06, deployed 2026-09-09** (reconciled 2026-09-09 — the row was still `backlog` while both of its bugs were `Status: Fixed`). The row is a **disjunction** and the *re-pull* branch is the one that shipped: `src/agent/image-refresh.ts` decides a refresh on a version change, a changed reference and — the case that matters on every host that upgraded before this code existed — **no record at all**; `ensureAgentImage()` drives pull/inspect/record through injected deps so the call site itself is testable; the record under `<dataDir>/image-pull.json` is written only after a pull that returned 0. The clause that binds "either way" also shipped: boot logs one `Agent image resolved` line with reference, image id, repo digest, version label, host version, refresh outcome + reason and `previousImageId` when the tag moved. `src/host-version.ts` is the single reader of the host's own version, shared with `getVersion()` and preflight's `image.contract`. The host half the row explicitly took with it shipped in the same round: `/health` reads the installed `package.json`, with `MERCURY_VERSION` demoted to an override and blank treated as unset. Post-mortems: [`ensure-image-never-refreshes-a-floating-tag`](../../debug/major/2026-09-06-ensure-image-never-refreshes-a-floating-tag.md), [`health-reports-stale-env-version`](../../debug/moderate/2026-09-06-health-reports-stale-env-version.md). The **visibility** half of the slug is discharged by decision, not code — G-007 amended 2026-09-07: private for now. **Two residuals, both split out rather than left inside a done row**: the *pin* branch is now unblocked and is **R0.6**; and the refresh path has never been exercised against a registry — see R0.6's note |
|
|
39
|
-
| R0.5 | **`mercury upgrade` owns both halves.** `upgradeAction` contains no Docker call at all: it stops the service, npm-installs and restarts, leaving the image behind. It should pull the matching image first and refuse when that tag does not exist — image first, host second, the rule the fleet updater already follows and the one 0.17.0 broke | upgrade-pulls-image | R0.1 | **landed 2026-09-13** (B4 of the bench-and-gate round) — the flow moved out of `mercury.ts` into `runUpgrade` in `src/cli/upgrade.ts` over injected deps (resolver, pull, service stop/start, install, config read) so the suite drives it with a recording fake and no real `docker pull` or `npm install` ever runs. Order: Windows DLL probe → resolve the spec to **one exact version** (`npm view` for `latest`/a tag/a range; refuses otherwise) → decide the image (the default pinned to the *target* version; an explicit registry override pulled as configured; a local build refused without `--skip-image-pull`, G-009) → print the plan → `--dry-run` stops here → `docker pull` (a failure refuses with the reason classified — tag unpublished / credential refused / no docker / stderr tail — and the host untouched, negative-tested) → stop → `npm install -g <exact version>` → start → next step named (`mercury preflight`). New options `--dry-run`, `--skip-image-pull`. `hasRegistryHost` split out of `isPullableRef` so the override decision shares its rule. Docs: `deployment.md` § Upgrading, README. Deploy: CLI-only (source on this box) — the box never runs `upgrade`; its live check is `--dry-run`. Spec: [`docs/archive/2026-09-13-upgrade-owns-both-halves.md`](../../archive/2026-09-13-upgrade-owns-both-halves.md) |
|
|
40
|
-
| R0.6 | **Pin the default agent image to the version that is running.** `DEFAULT_AGENT_CONTAINER_IMAGE` is still `ghcr.io/avishai-tsabari/mercury-agent:latest` (`src/config.ts:97`). R0.4 deliberately shipped only its re-pull branch because Mercury published no per-version tag, and the pin would have made every fresh install fail on a tag nobody pushed. **That precondition is gone**: R0.3 is done since 2026-09-07 and `:0.20.0` and `:latest` share one digest, so `:${pkg.version}` now resolves. Pinning turns "is this tag current" from a bookkeeping question into one a fresh `docker image inspect` answers, and it is what lets `mercury upgrade` (R0.5) name the tag it must pull. Carry two constraints across: the **derived** `mercury-agent-ext:<hash>` tag and the local `mercury build` tag have no registry host and must stay unpullable (`isPullableRef`), and a box whose configured image is an explicit override must keep it. Also owed here, because it cannot be closed anywhere else: **the R0.4 refresh path has never run against a registry.** The owner's box runs a locally-built `mercury-agent:latest`, which `isPullableRef` excludes by design, and Mercury's GHCR package is private (G-007), so no anonymous pull is possible — the evidence (`Agent image resolved … refresh=…`, `<dataDir>/image-pull.json` appearing) has to be collected on a fleet box with a pull token, or with a token configured here | pin-default-image-tag | R0.3, R0.4 | **landed 2026-09-12** (B4 of the bench-and-gate round) — `DEFAULT_AGENT_CONTAINER_IMAGE` is `defaultAgentContainerImage(readHostVersion(<package root>))` = `ghcr.io/avishai-tsabari/mercury-agent:<version>`; an unreadable version falls back to `:latest` by name (G-008). `isPullableRef` unchanged, now asserted per tag shape (pinned default incl. prerelease, `mercury-agent:latest`, `mercury-agent:<version>` as `build.sh` tags it, `mercury-agent-ext*:<hash>`); an explicit override is proven byte-for-byte through env and `mercury.yaml`. **Fails at preflight, not at the first spawn**: `docker.image` gains `evidence.pinnedDefault` and a remedy naming the pin, the host version and the two ways out (a token that reads the private package, or an explicit `agent.image`) — negative-tested. `decideImageRefresh` untouched: on a default box a host upgrade now changes the *reference*, so the not-present pull path serves it; the version-changed re-pull keeps serving explicit floating overrides. **The R0.4 registry evidence is still uncollected** — written as an owner-owned line (token on this box, or a fleet box) under `pin-default-agent-image (R0.6)` in `pending-verification.md`. Deploy: restart-only, **after** C-B confirms the box is on an explicit override (§5 step 6 of the round note). Spec: [`docs/archive/2026-09-12-pin-default-agent-image.md`](../../archive/2026-09-12-pin-default-agent-image.md) |
|
|
39
|
+
| R0.5 | **`mercury upgrade` owns both halves.** `upgradeAction` contains no Docker call at all: it stops the service, npm-installs and restarts, leaving the image behind. It should pull the matching image first and refuse when that tag does not exist — image first, host second, the rule the fleet updater already follows and the one 0.17.0 broke | upgrade-pulls-image | R0.1 | **landed 2026-09-13** (B4 of the bench-and-gate round) — the flow moved out of `mercury.ts` into `runUpgrade` in `src/cli/upgrade.ts` over injected deps (resolver, pull, service stop/start, install, config read) so the suite drives it with a recording fake and no real `docker pull` or `npm install` ever runs. Order: Windows DLL probe → resolve the spec to **one exact version** (`npm view` for `latest`/a tag/a range; refuses otherwise) → decide the image (the default pinned to the *target* version; an explicit registry override pulled as configured; a local build refused without `--skip-image-pull`, G-009) → print the plan → `--dry-run` stops here → `docker pull` (a failure refuses with the reason classified — tag unpublished / credential refused / no docker / stderr tail — and the host untouched, negative-tested) → stop → `npm install -g <exact version>` → start → next step named (`mercury preflight`). New options `--dry-run`, `--skip-image-pull`. `hasRegistryHost` split out of `isPullableRef` so the override decision shares its rule. Docs: `deployment.md` § Upgrading, README. Deploy: CLI-only (source on this box) — the box never runs `upgrade`; its live check is `--dry-run`. Spec: [`docs/archive/release-gate/2026-09-13-upgrade-owns-both-halves.md`](../../archive/release-gate/2026-09-13-upgrade-owns-both-halves.md) |
|
|
40
|
+
| R0.6 | **Pin the default agent image to the version that is running.** `DEFAULT_AGENT_CONTAINER_IMAGE` is still `ghcr.io/avishai-tsabari/mercury-agent:latest` (`src/config.ts:97`). R0.4 deliberately shipped only its re-pull branch because Mercury published no per-version tag, and the pin would have made every fresh install fail on a tag nobody pushed. **That precondition is gone**: R0.3 is done since 2026-09-07 and `:0.20.0` and `:latest` share one digest, so `:${pkg.version}` now resolves. Pinning turns "is this tag current" from a bookkeeping question into one a fresh `docker image inspect` answers, and it is what lets `mercury upgrade` (R0.5) name the tag it must pull. Carry two constraints across: the **derived** `mercury-agent-ext:<hash>` tag and the local `mercury build` tag have no registry host and must stay unpullable (`isPullableRef`), and a box whose configured image is an explicit override must keep it. Also owed here, because it cannot be closed anywhere else: **the R0.4 refresh path has never run against a registry.** The owner's box runs a locally-built `mercury-agent:latest`, which `isPullableRef` excludes by design, and Mercury's GHCR package is private (G-007), so no anonymous pull is possible — the evidence (`Agent image resolved … refresh=…`, `<dataDir>/image-pull.json` appearing) has to be collected on a fleet box with a pull token, or with a token configured here | pin-default-image-tag | R0.3, R0.4 | **landed 2026-09-12** (B4 of the bench-and-gate round) — `DEFAULT_AGENT_CONTAINER_IMAGE` is `defaultAgentContainerImage(readHostVersion(<package root>))` = `ghcr.io/avishai-tsabari/mercury-agent:<version>`; an unreadable version falls back to `:latest` by name (G-008). `isPullableRef` unchanged, now asserted per tag shape (pinned default incl. prerelease, `mercury-agent:latest`, `mercury-agent:<version>` as `build.sh` tags it, `mercury-agent-ext*:<hash>`); an explicit override is proven byte-for-byte through env and `mercury.yaml`. **Fails at preflight, not at the first spawn**: `docker.image` gains `evidence.pinnedDefault` and a remedy naming the pin, the host version and the two ways out (a token that reads the private package, or an explicit `agent.image`) — negative-tested. `decideImageRefresh` untouched: on a default box a host upgrade now changes the *reference*, so the not-present pull path serves it; the version-changed re-pull keeps serving explicit floating overrides. **The R0.4 registry evidence is still uncollected** — written as an owner-owned line (token on this box, or a fleet box) under `pin-default-agent-image (R0.6)` in `pending-verification.md`. Deploy: restart-only, **after** C-B confirms the box is on an explicit override (§5 step 6 of the round note). Spec: [`docs/archive/release-gate/2026-09-12-pin-default-agent-image.md`](../../archive/release-gate/2026-09-12-pin-default-agent-image.md) |
|
|
41
41
|
|
|
42
42
|
### Milestone R1: Preflight
|
|
43
43
|
> After this, a box that cannot run a container says so at boot and on
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
|
|
47
47
|
| ID | Story | Slug | Depends on | Status |
|
|
48
48
|
|----|-------|------|------------|--------|
|
|
49
|
-
| preflight | — | **done 2026-09-06** — merged into local `dev` as `5298d26` after a nine-finding audit round; spec archived at [`docs/archive/preflight.md`](../../archive/preflight.md). First run on the box 2026-09-06 (`631c3b5`, image `mercury-agent-ext:a5a60d5b20c9`) blocked on `container.mrctl-roundtrip` 401 until the owner set `MERCURY_CALLER_TOKEN_KEY` at 18:19 IDT; **9 pass / 0 blocked, exit 0 on `2a844c79ee48` 2026-09-07**, and the header-identity round trip (no caller-token key, no `space_roles` row left behind) proven the same day. ROADMAP row moved to Shipped by `72dc67e` |
|
|
50
|
-
| R1.2 | `mercury doctor` calls `loadConfig` and delegates its environment checks to R1.1 so there is one implementation; boot logs the same report once; a *blocked* verdict at boot refuses spawns with the diagnosis (already the case for the two probes — generalised) | doctor-delegates-to-preflight | R1.1 | **landed 2026-09-07, deployed 2026-09-09** (released in 0.21.0) — the runner gained a scope (`full` / `boot` / `environment`) and one shared deps builder; `doctor` now runs the `environment` scope (the checks that spawn no container) and renders it in its own idiom, `src/main.ts` runs the `boot` scope in place of its two direct probe calls and logs one report. **The refusal was narrowed on purpose**: only a verdict that condemns every future spawn *and* cannot self-heal latches, which today is exactly the two sandbox probes — `sandbox.runsc-host-uds` joins `sandbox.bwrap`, keyed on the *probe's* own verdict kinds so `inconclusive` never latches. `image.contract` blocks by design on every box whose image predates R0.1 and `credential.first-leg` judges one leg of a per-run choice, so neither latches; both are logged at `error`. Only a `full` run writes a report file — a partial one under `latest.json` would be the green-`/health` failure in the gate's own file. Spec: [`docs/archive/2026-09-07-doctor-delegates-to-preflight.md`](../../archive/2026-09-07-doctor-delegates-to-preflight.md) |
|
|
49
|
+
| preflight | — | **done 2026-09-06** — merged into local `dev` as `5298d26` after a nine-finding audit round; spec archived at [`docs/archive/release-gate/preflight.md`](../../archive/release-gate/preflight.md). First run on the box 2026-09-06 (`631c3b5`, image `mercury-agent-ext:a5a60d5b20c9`) blocked on `container.mrctl-roundtrip` 401 until the owner set `MERCURY_CALLER_TOKEN_KEY` at 18:19 IDT; **9 pass / 0 blocked, exit 0 on `2a844c79ee48` 2026-09-07**, and the header-identity round trip (no caller-token key, no `space_roles` row left behind) proven the same day. ROADMAP row moved to Shipped by `72dc67e` |
|
|
50
|
+
| R1.2 | `mercury doctor` calls `loadConfig` and delegates its environment checks to R1.1 so there is one implementation; boot logs the same report once; a *blocked* verdict at boot refuses spawns with the diagnosis (already the case for the two probes — generalised) | doctor-delegates-to-preflight | R1.1 | **landed 2026-09-07, deployed 2026-09-09** (released in 0.21.0) — the runner gained a scope (`full` / `boot` / `environment`) and one shared deps builder; `doctor` now runs the `environment` scope (the checks that spawn no container) and renders it in its own idiom, `src/main.ts` runs the `boot` scope in place of its two direct probe calls and logs one report. **The refusal was narrowed on purpose**: only a verdict that condemns every future spawn *and* cannot self-heal latches, which today is exactly the two sandbox probes — `sandbox.runsc-host-uds` joins `sandbox.bwrap`, keyed on the *probe's* own verdict kinds so `inconclusive` never latches. `image.contract` blocks by design on every box whose image predates R0.1 and `credential.first-leg` judges one leg of a per-run choice, so neither latches; both are logged at `error`. Only a `full` run writes a report file — a partial one under `latest.json` would be the green-`/health` failure in the gate's own file. Spec: [`docs/archive/release-gate/2026-09-07-doctor-delegates-to-preflight.md`](../../archive/release-gate/2026-09-07-doctor-delegates-to-preflight.md) |
|
|
51
51
|
|
|
52
52
|
**Checkpoint:** on the tagula box (with the owner's approval and in a cron
|
|
53
53
|
gap) `mercury preflight` exits zero; with `--host-uds=open` temporarily
|
|
@@ -60,8 +60,8 @@ disabled it names both Docker gates. Report attached to this roadmap.
|
|
|
60
60
|
|
|
61
61
|
| ID | Story | Slug | Depends on | Status |
|
|
62
62
|
|----|-------|------|------------|--------|
|
|
63
|
-
| R2.1 | `mercury env dump [--json]`: a normalised manifest of everything that is not data — mercury/bun/pi versions, image ref + digest, container runtime, sandbox verdict (from R1), daemon.json runtime args, the two userns sysctls, seccomp/AppArmor status, effective `mercury.yaml` (non-secret keys, with source: file/env/default), model legs, trigger aliases, timezone + count of active tasks with `timezone:null`, passthrough mode and the list it admits, loaded/skipped extensions with reasons, `.env` **key names** only. Never a value that could be a secret; a test plants a fake key and asserts it is absent | env-dump | R1.1 | **landed 2026-09-07, deployed 2026-09-09** (released in 0.21.0) — `mercury env dump [--json]` prints one manifest (host, versions, docker, sandbox, image, config, model, triggers, passthrough, tasks, extensions, env key names) and judges nothing: exit 0 whatever the host looks like, 2 only when the config will not load, and the `sandbox` section is a summary of preflight's **last** report rather than a fresh opinion. G-005 is discharged by an **allowlist**, not a redactor: a value is printed only for a key in `SAFE_CONFIG_KEYS`, so a key added upstream appears by name and source with no value until someone reviews it — asserted not to overlap `SECRET_SCHEMA_KEYS`, and proved by a CLI test that plants a real value in all three inputs (`process.env`, the project `.env`, `mercury.yaml`), proves each plant, then greps both output formats. Config provenance comes from the merge itself (`mergeRawMercuryConfigWithSources`, which `mergeRawMercuryConfig` now delegates to), so the dump cannot disagree with `loadConfig` about where a value came from — including the present-but-empty `MERCURY_*` that reads as an override. Spec: [`docs/archive/2026-09-07-env-dump.md`](../../archive/2026-09-07-env-dump.md) |
|
|
64
|
-
| R2.2 | `mercury env diff <a.json> <b.json>`: line-per-difference, grouped by class (host / runtime / config / extensions), exit non-zero on differences in a *blocking* class (runtime, sandbox, versions) so a migration runbook can gate on it; the tagula migration runbook and `shadow restore` (bench M1.6) print it | env-diff | R2.1 | **landed 2026-09-09** — `mercury env diff <a.json> <b.json> [--json]`, one line per difference, grouped by class, exit **1** on a blocking difference, **0** when the only differences are advisory, **2** when a file cannot be read or is not a manifest (a caller gating on 1 must never confuse "the hosts differ" with "I could not look"). Blocking is a property of the *class*, so the policy is one readable table — `versions`, `runtime` (docker + image) and `sandbox` — plus `unclassified`, which is what a section no rule covers gets, and which blocks: a manifest that grew a section is a manifest whose new section might be the sandbox. A typed `EnvManifest` fixture in the test makes an unclassified section a **typecheck** failure before it is a coverage failure. The contract block above is discharged exactly: `generatedAt` ignored, `versions.pi` reported but never blocking, and both are exported constants with a red test each. Array comparison is a policy table rather than an inference (`set` / `keyed` / `ordered`, default `ordered` — the strictest, so a list added upstream can be noisy but never silently equal); that is what turns the dropped fifth model leg into `model.chain only in A: …gemini-2.5-pro` instead of two 160-character JSON blobs with the same visible prefix. **D-6: the command reads its two arguments and nothing else** — no `loadConfig`, no `.env`, no database, no Docker — because both consumers compare hosts that are not the one running it, and a test runs it from an empty directory. Two recorded dumps under `tests/fixtures/env-dump/` carry all five drifts of the goal's success criterion 2. Spec: [`docs/archive/2026-09-09-env-diff.md`](../../archive/2026-09-09-env-diff.md) |
|
|
63
|
+
| R2.1 | `mercury env dump [--json]`: a normalised manifest of everything that is not data — mercury/bun/pi versions, image ref + digest, container runtime, sandbox verdict (from R1), daemon.json runtime args, the two userns sysctls, seccomp/AppArmor status, effective `mercury.yaml` (non-secret keys, with source: file/env/default), model legs, trigger aliases, timezone + count of active tasks with `timezone:null`, passthrough mode and the list it admits, loaded/skipped extensions with reasons, `.env` **key names** only. Never a value that could be a secret; a test plants a fake key and asserts it is absent | env-dump | R1.1 | **landed 2026-09-07, deployed 2026-09-09** (released in 0.21.0) — `mercury env dump [--json]` prints one manifest (host, versions, docker, sandbox, image, config, model, triggers, passthrough, tasks, extensions, env key names) and judges nothing: exit 0 whatever the host looks like, 2 only when the config will not load, and the `sandbox` section is a summary of preflight's **last** report rather than a fresh opinion. G-005 is discharged by an **allowlist**, not a redactor: a value is printed only for a key in `SAFE_CONFIG_KEYS`, so a key added upstream appears by name and source with no value until someone reviews it — asserted not to overlap `SECRET_SCHEMA_KEYS`, and proved by a CLI test that plants a real value in all three inputs (`process.env`, the project `.env`, `mercury.yaml`), proves each plant, then greps both output formats. Config provenance comes from the merge itself (`mergeRawMercuryConfigWithSources`, which `mergeRawMercuryConfig` now delegates to), so the dump cannot disagree with `loadConfig` about where a value came from — including the present-but-empty `MERCURY_*` that reads as an override. Spec: [`docs/archive/release-gate/2026-09-07-env-dump.md`](../../archive/release-gate/2026-09-07-env-dump.md) |
|
|
64
|
+
| R2.2 | `mercury env diff <a.json> <b.json>`: line-per-difference, grouped by class (host / runtime / config / extensions), exit non-zero on differences in a *blocking* class (runtime, sandbox, versions) so a migration runbook can gate on it; the tagula migration runbook and `shadow restore` (bench M1.6) print it | env-diff | R2.1 | **landed 2026-09-09** — `mercury env diff <a.json> <b.json> [--json]`, one line per difference, grouped by class, exit **1** on a blocking difference, **0** when the only differences are advisory, **2** when a file cannot be read or is not a manifest (a caller gating on 1 must never confuse "the hosts differ" with "I could not look"). Blocking is a property of the *class*, so the policy is one readable table — `versions`, `runtime` (docker + image) and `sandbox` — plus `unclassified`, which is what a section no rule covers gets, and which blocks: a manifest that grew a section is a manifest whose new section might be the sandbox. A typed `EnvManifest` fixture in the test makes an unclassified section a **typecheck** failure before it is a coverage failure. The contract block above is discharged exactly: `generatedAt` ignored, `versions.pi` reported but never blocking, and both are exported constants with a red test each. Array comparison is a policy table rather than an inference (`set` / `keyed` / `ordered`, default `ordered` — the strictest, so a list added upstream can be noisy but never silently equal); that is what turns the dropped fifth model leg into `model.chain only in A: …gemini-2.5-pro` instead of two 160-character JSON blobs with the same visible prefix. **D-6: the command reads its two arguments and nothing else** — no `loadConfig`, no `.env`, no database, no Docker — because both consumers compare hosts that are not the one running it, and a test runs it from an empty directory. Two recorded dumps under `tests/fixtures/env-dump/` carry all five drifts of the goal's success criterion 2. Spec: [`docs/archive/release-gate/2026-09-09-env-diff.md`](../../archive/release-gate/2026-09-09-env-diff.md) |
|
|
65
65
|
|
|
66
66
|
**R2.2 contract — fields excluded from the diff.** Two fields of an R2.1
|
|
67
67
|
manifest describe the *invocation*, not the host, and change between two runs
|
|
@@ -99,7 +99,7 @@ evidence, after a grep proves no secret value is in them.
|
|
|
99
99
|
|----|-------|------|------------|--------|
|
|
100
100
|
| R3.1 | **Fixture format**: one file per space (`smoke.yaml` under the space's profile or workspace), each fixture = sender role (`member` / `admin` / `owner`), message (text, optional quoted message, optional file), timeout, and assertions on *shape* only: replied-within, not-a-refusal (a small refusal lexicon per language, absence denies), produced-file (kind + non-empty), lint passes (`reporter-lint` where the space has a standard), optional regex on structure. Documented with three worked examples (a DM answer, a group mention, a proposal PDF) | smoke-fixture-format | — | **planned 2026-09-09**, not started — Goal and Architecture in [`docs/backlog/smoke-fixture-format.md`](../../backlog/smoke-fixture-format.md), written by the round's S2 session after R2.2 landed. Four decisions past the row: D-11 YAML validated by a `.strict()` zod schema, so a misspelled assertion key is a **refusal** rather than a fixture that asserts nothing; D-12 resolution is a fixed order (`--fixtures` → the space's workspace → its profile) and the parse result names the file that won, which is what keeps customer fixtures out of this repo (G-006); D-13 the refusal lexicon is per language and **absence denies** — an unknown language fails `not-a-refusal` instead of passing it, and `isRefusal` returns a third case so a caller cannot ignore it; D-14 `lint` delegates to `reporter-lint`'s existing `evaluateReplyCase` shape rather than re-implementing a comparison. Two open questions are the owner's: the lexicon's contents (a false positive there fails a healthy deploy) and whether `produced-file` may name a filename pattern. **Built 2026-09-12 (B3, bench-and-gate round), gate-green on its branch, unlanded at the time of writing** — `src/smoke/fixture.ts` (strict zod, an unknown key names its file and line), `src/smoke/refusal.ts` (`isRefusal` with a third `unknownLanguage` case), three worked examples as real files under `examples/smoke/`, `docs/smoke-fixtures.md`; three deviations the code forced are recorded there (`mentionBot`, `lint.standard` as a space-relative path, examples outside the football profile). Spec: [`docs/archive/2026-09-13-smoke-fixture-format.md`](../../archive/rehearsal/2026-09-13-smoke-fixture-format.md) |
|
|
101
101
|
| R3.2 | `mercury smoke --space <id> [--fixtures <file>] [--json]`: runs each fixture through `simulateIngress` (bench M2.2, function half) in the current process — a shadow, or a designated test space on live when the caller says so — as the fixture's role, records `sent[]`, evaluates the assertions, writes a report with per-fixture verdict, transcript, elapsed and cost from `token_usage`. Refuses on live unless the target space is named in config as a test space (absence denies) | smoke-runner | R3.1, rehearsal-bench M2.2 | **built 2026-09-12/13 (B3), gate-green on its branch, unlanded at the time of writing** — `mercury smoke --space <id> [--fixtures] [--profile] [--json] [--report]`, its own process booting a headless shadow (`src/smoke/boot.ts`, a documented twin of `main.ts` — idea `shared-runtime-boot`), one pure evaluation (`src/smoke/evaluate.ts`), the report beside preflight's with cost since the run started, exit 0/1/2. **The live leg and the test-space key are dropped: G-010.** The role is a chosen sender and the run's own read-back is the verdict (owner = first configured admin; absent denies). Live check is shadow-side, deploy 3. Spec: [`docs/archive/2026-09-13-smoke-runner.md`](../../archive/rehearsal/2026-09-13-smoke-runner.md) |
|
|
102
|
-
| R3.3 | Regression map: `tests/release-gate-map.test.ts` asserts every post-mortem under `docs/debug/**` since 2026-09-01 names a check in `preflight`, `smoke` or `env diff` (by a `Gate check:` line) — the football regression-map pattern applied to the gate | gate-regression-map | R1.1, R3.2 | **built 2026-09-13 (B3), gate-green on its branch** — `tests/release-gate-map.test.ts` + `tests/fixtures/release-gate/regression-map.json` (97 allowlist entries on day one, 14 of them `gate-candidate` naming the check that should own the failure); an allowlisted file that gains a line is refused; G-011. Every post-mortem archived from here on must carry a `Gate check:` line or an entry. Spec: [`docs/archive/2026-09-13-gate-regression-map.md`](../../archive/2026-09-13-gate-regression-map.md) |
|
|
102
|
+
| R3.3 | Regression map: `tests/release-gate-map.test.ts` asserts every post-mortem under `docs/debug/**` since 2026-09-01 names a check in `preflight`, `smoke` or `env diff` (by a `Gate check:` line) — the football regression-map pattern applied to the gate | gate-regression-map | R1.1, R3.2 | **built 2026-09-13 (B3), gate-green on its branch** — `tests/release-gate-map.test.ts` + `tests/fixtures/release-gate/regression-map.json` (97 allowlist entries on day one, 14 of them `gate-candidate` naming the check that should own the failure); an allowlisted file that gains a line is refused; G-011. Every post-mortem archived from here on must carry a `Gate check:` line or an entry. Spec: [`docs/archive/release-gate/2026-09-13-gate-regression-map.md`](../../archive/release-gate/2026-09-13-gate-regression-map.md) |
|
|
103
103
|
|
|
104
104
|
**Checkpoint:** a fixture file for `football-friends` and one for a copy of
|
|
105
105
|
the copywriter space; the 08-23 instruction file applied to the shadow
|
|
@@ -112,7 +112,7 @@ passes. Cost of the run in the report.
|
|
|
112
112
|
|
|
113
113
|
| ID | Story | Slug | Depends on | Status |
|
|
114
114
|
|----|-------|------|------------|--------|
|
|
115
|
-
| R4.1 | `docs/release-gate.md`: the twelve gates of the review note (§6), each with the command that fails, the evidence it produces and where that evidence goes; the change classification (code / image / environment / config / profile / data) and the minimum ring per class; the staged-rollout and dwell rule as a *policy hook* the fleet fills in; `docs/live-testing.md` reduced to rings, send-site and side-effect inventories and the residue tables, pointing here for the rules; `CLAUDE.md` Gotchas gain one line | release-gate-rules | R1.1 | **landed 2026-09-12** (B4 of `docs/notes/2026-09-12-bench-and-gate-round.md`) — [`docs/release-gate.md`](../../release-gate.md): the twelve gates as tables (rule, command that fails with its exit code or refusal id, evidence, where it goes, status), the six-class change classification with a discriminating command and a minimum ring per class, the rollout **hook** (§3: the evidence Mercury provides, the predicate, the blanks the fleet fills), the evidence block a deploy doc pastes (§4) and a Struck table (§5). Nothing was struck outright under G-004: every draft rule found an existing command, a planned story (`smoke` steps *land with R3.2*, the regression map with R3.3, the path wiring with R5.1/R5.2) or a fleet hook with the evidence shape stated; Gate 4 is written as **not in force** until R5.1/R5.2. `live-testing.md` §6 and §8 are now pointer sections (numbering kept), `CLAUDE.md` gained its Gotcha line. Spec: [`docs/archive/2026-09-12-release-gate-doc.md`](../../archive/2026-09-12-release-gate-doc.md). **Checkpoint still open**: the owner reads and strikes |
|
|
115
|
+
| R4.1 | `docs/release-gate.md`: the twelve gates of the review note (§6), each with the command that fails, the evidence it produces and where that evidence goes; the change classification (code / image / environment / config / profile / data) and the minimum ring per class; the staged-rollout and dwell rule as a *policy hook* the fleet fills in; `docs/live-testing.md` reduced to rings, send-site and side-effect inventories and the residue tables, pointing here for the rules; `CLAUDE.md` Gotchas gain one line | release-gate-rules | R1.1 | **landed 2026-09-12** (B4 of `docs/notes/2026-09-12-bench-and-gate-round.md`) — [`docs/release-gate.md`](../../release-gate.md): the twelve gates as tables (rule, command that fails with its exit code or refusal id, evidence, where it goes, status), the six-class change classification with a discriminating command and a minimum ring per class, the rollout **hook** (§3: the evidence Mercury provides, the predicate, the blanks the fleet fills), the evidence block a deploy doc pastes (§4) and a Struck table (§5). Nothing was struck outright under G-004: every draft rule found an existing command, a planned story (`smoke` steps *land with R3.2*, the regression map with R3.3, the path wiring with R5.1/R5.2) or a fleet hook with the evidence shape stated; Gate 4 is written as **not in force** until R5.1/R5.2. `live-testing.md` §6 and §8 are now pointer sections (numbering kept), `CLAUDE.md` gained its Gotcha line. Spec: [`docs/archive/release-gate/2026-09-12-release-gate-doc.md`](../../archive/release-gate/2026-09-12-release-gate-doc.md). **Checkpoint still open**: the owner reads and strikes |
|
|
116
116
|
|
|
117
117
|
**Checkpoint:** the owner reads the document and either accepts each rule's
|
|
118
118
|
command or strikes the rule; no rule survives without a command.
|
|
@@ -313,6 +313,24 @@ the per-story specs in `docs/goals/whatsapp-bot-hardening/archive/`.
|
|
|
313
313
|
| E-7 | "Make me a doc with X" → `.docx` arrives and opens in Word, triggered by a **member**, not the admin | M4 |
|
|
314
314
|
| E-8 | Unlink a throwaway group, then re-link it entirely from chat | M3 |
|
|
315
315
|
|
|
316
|
+
**Shadow rehearsal 2026-09-25 01:53–02:01 IDT — all eight run, all eight pass
|
|
317
|
+
on the host code path.** Shadow on snapshot `2026-09-24T23-53-01-141Z`
|
|
318
|
+
(`dev` @ `b9d9a4e`, the live commit), driven by `POST /api/shadow/simulate`
|
|
319
|
+
with real member jids and real pre-restart platform message ids; 6 model runs,
|
|
320
|
+
$0.45; live uptime unbroken, no token refresh on the shadow. What the shadow
|
|
321
|
+
cannot prove is the phone/socket leg — each row says what is left.
|
|
322
|
+
|
|
323
|
+
| # | Shadow result | Left for the phone |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| E-1 | 3 untagged → exactly 3 `ambient` rows, PN `author_id`, real names. (A first try with a *bare LID* sender and no pushName stored the LID and digits — the simulator does not canonicalize `senderJid` the way `resolveKeyIdentities` does live; a fidelity gap, not a live bug: 942 live rows, 0 LID authors.) | nothing — live DB already shows the socket leg |
|
|
326
|
+
| E-2 | "who said the ferry leaves at 7:40?" → the right author, and it said plainly it could not name the LID-digits row | nothing |
|
|
327
|
+
| E-3 | Fresh process (no in-memory push names). Quote of a stored pre-restart message with a **LID** participant → `<reply_anchor>` with a name; quote of an unstored id with a LID participant → `<reply_to name=…>` a display name, no `@lid`; both answers correct | nothing — 58 live quote-replies already rendered names |
|
|
328
|
+
| E-4 | Throwaway group linked to `e4-scratch`; `/spaces delete e4-victim` → swipe-reply "yes" (quoted `fromBot`) → victim deleted, `e4-scratch` and the link survive | nothing |
|
|
329
|
+
| E-5 | Bare untagged `/spaces list` in greece2026: admin gets the list, member gets the permission refusal | nothing |
|
|
330
|
+
| E-6 | 45 MB docx (route body cap is 50 MB) → read, answered from the text. Oversize: shadow cap lowered to 20 MB, same file → dropped `too_large` → reply says size and limit and what to send instead (the adapter's pre-download drop emits the same record) | **done on the phone 2026-09-25 03:09 IDT**: owner sent a 123 MB document to `main`; live journal `Skipping large media file … sizeBytes=129133178 maxBytes=67108864` (dropped before download), the model got the `too large (123 MB, limit 64 MB)` system note, and the reply gave size, limit and what to send instead, inventing nothing |
|
|
331
|
+
| E-7 | Member-triggered → `island-day-trip-packing-checklist.docx` 36 KB, valid OOXML, all four items in `word/document.xml` | **done on the phone 2026-09-25 03:17 IDT**: the same file, sent to `main`, opened in Word. The first send silently failed (`cp -p` kept an old mtime, `scanOutbox` skipped it, the model still said "attached") — `bugs/outbox-scan-drops-files-whose-mtime-predates-the-run.md` |
|
|
332
|
+
| E-8 | API unlink → group deaf (`not-linked`) → in-group `/spaces list` + `/spaces switch e4-scratch` by a seeded admin relinked it; DM-side `/spaces link <id> <space>` also worked | nothing |
|
|
333
|
+
|
|
316
334
|
⚠️ E-1 to E-3 want `log_level: debug` for the test window only — it writes
|
|
317
335
|
chat-content previews into journald, i.e. real family/friends chat into system
|
|
318
336
|
logs. **Set it back to `info` afterwards.**
|
|
@@ -90,7 +90,7 @@ ask the bot what it knows about a member → answer draws on
|
|
|
90
90
|
|
|
91
91
|
| ID | Story | Slug | Depends on | Status |
|
|
92
92
|
|----|-------|------|------------|--------|
|
|
93
|
-
| M5.1 | Box honesty — dead-man's-switch heartbeat (`MERCURY_HEARTBEAT_URL`/`_TASKS`, `host.heartbeat` preflight), napkin's host-side pi run bounded (wall-clock + `bash-timeout`), `space-profile check`/`dump` read-only (audit C1), `workspace_write` repo-owned files | box-honesty ([spec](../../archive/2026-09-12-box-honesty.md)) | — | **gate-green on `worktree-feature-box-honesty`**, landing is the coordinator's |
|
|
93
|
+
| M5.1 | Box honesty — dead-man's-switch heartbeat (`MERCURY_HEARTBEAT_URL`/`_TASKS`, `host.heartbeat` preflight), napkin's host-side pi run bounded (wall-clock + `bash-timeout`), `space-profile check`/`dump` read-only (audit C1), `workspace_write` repo-owned files | box-honesty ([spec](../../archive/ops/2026-09-12-box-honesty.md)) | — | **gate-green on `worktree-feature-box-honesty`**, landing is the coordinator's |
|
|
94
94
|
|
|
95
95
|
**Checkpoint:** the owner's phone hears a missed 09:00 by 09:30 with the box
|
|
96
96
|
still down; `mercury preflight` warns on a box with tasks and no URL; a hung
|
|
@@ -7,6 +7,7 @@ On `SIGTERM` or `SIGINT`, mercury tears down all components in order instead of
|
|
|
7
7
|
```
|
|
8
8
|
SIGTERM/SIGINT received
|
|
9
9
|
│
|
|
10
|
+
├─0─► Save what the debouncers still hold (sync, one SQLite transaction)
|
|
10
11
|
├─1─► Stop scheduler (clear poll timer)
|
|
11
12
|
├─2─► Cancel all pending queue entries
|
|
12
13
|
├─3─► Kill running containers (docker kill)
|
|
@@ -22,14 +23,27 @@ SIGTERM/SIGINT received
|
|
|
22
23
|
|
|
23
24
|
## Why this order
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
0. **Debounced messages first** — synchronous `onBeforeShutdown` hooks run before anything else. Each message handler closes its `MessageDebouncer`: every message still waiting in a batch, or parked behind an in-flight run, is written to `pending_ingress` in one transaction, and every debounce timer is cleared. None of these messages has reached `messages` yet (that write happens when a run starts), so this row is their only trace. Flushing them instead cannot work: step 2 cancels the queue and step 3 kills the containers before anything they started could finish, inside the 10 s budget. Anything that reaches the handler after this point is saved rather than run: a message arriving before step 5 disconnects the adapters (commands included), or a run settling with messages parked behind it. Journal line: `Shutdown: saved pending ingress` with `messages`, `rows` and `elapsedMs`. A save that fails is logged with key, count and a text preview: `Pending ingress could not be saved; messages lost`.
|
|
27
|
+
1. **Scheduler** — prevents new work from being created while we're shutting down. It also marks the scheduler *stopping*: a scheduled run whose container step 3 kills is closed in the run ledger as `interrupted` (not `error`), with no retry, no "task failed" notice and no change to its schedule, so the next boot can apply the task's catch-up policy. A run that step 7's 10 s force-exit cuts off before that write is left `running`, and the next boot's reconciliation closes it the same way (see [configuration.md](./configuration.md#interrupted-is-not-missed)).
|
|
28
|
+
2. **Cancel pending queue entries** — no point starting queued work we'll just kill. Every dropped entry's promise **rejects** with `ContainerError.shutdown`, so its caller runs its own path instead of hanging until exit: a queued chat message is left for the next boot's notice (below), a queued scheduled run is ledgered `interrupted`.
|
|
29
|
+
3. **Kill containers** — uses `docker kill` for reliable termination. Falls back to `SIGKILL` if the docker command fails. Containers are labeled with `mercury.managed=true` for identification (see [container-lifecycle.md](./container-lifecycle.md)). The kill is recorded as reason `shutdown` (a user's `/stop` is `aborted`), so the run's caller throws `ContainerError.shutdown`, not "Stopped current run."
|
|
28
30
|
4. **Wait for drain** — gives active container runs a chance to finish cleanly (up to 8s).
|
|
29
31
|
5. **Disconnect adapters** — closes the WhatsApp socket, Slack/Discord connections. Done after containers so in-flight replies can still be posted.
|
|
30
32
|
6. **Stop HTTP server** — stops accepting new webhook/API requests.
|
|
31
33
|
7. **Close DB last** — everything above may still write to the database (message storage, task updates), so the DB connection stays open until the very end.
|
|
32
34
|
|
|
35
|
+
## Chat runs cut off by a shutdown are answered by the next boot
|
|
36
|
+
|
|
37
|
+
A chat message accepted into the space queue has a row in `chat_runs`, deleted once the handler has told the user something. A run cut off at step 2 or 3 gets **no reply now** — the handler logs `Run interrupted by shutdown; left open for the next boot` and leaves the ⏳ status message and the row in place. A reply sent here would race step 5's disconnect into WhatsApp's in-memory outgoing queue, "succeed" with a synthetic id and die with the process.
|
|
38
|
+
|
|
39
|
+
On the next boot, the `chat_run` reconciler (on the boot seam, `src/core/boot-reconcile.ts`) marks the rows the previous process left open, and after the adapters connect `deliverInterruptedChatRuns` deletes each recorded ⏳ and sends one "I was restarted before I could answer — please send it again" per thread. A hard kill (OOM, `SIGKILL`, the 10 s force-exit, WSL torn down) takes the same path: one delivery path for both legs. A `service uninstall` that never restarts sends nothing, which is right for a deliberate stop. Journal lines: `Reconciled interrupted work kind=chat_run`, `Interrupted chat run notified`, `Interrupted chat-run delivery finished` (printed even at zero). Rows older than 24 h, or after 3 failed delivery attempts, expire with `Interrupted chat run expired without notice`.
|
|
40
|
+
|
|
41
|
+
## Debounced messages are answered by the next boot
|
|
42
|
+
|
|
43
|
+
The `pending_ingress` reconciler is on the boot pass (`BOOT_RECONCILERS`, `src/core/boot-reconcile.ts`). Before any adapter starts, it drops rows that are unreadable (error) or were saved more than 15 minutes before this boot (warn, `Boot: pending ingress too old to answer; dropped`), because by then the chat has moved on. Neither case posts anything to the chat. After the adapters initialize, `replayPendingIngress` claims each live row (a `DELETE`, so at most one process answers it) and hands it to the handler for its platform. The handler re-submits the saved messages through the same debounce and dispatch path a live message takes, so a saved batch merges with a fresh message from the same caller, and the reply goes to each message's own thread. Journal line: `Boot: pending ingress` with `replayed`, `unowned`, `skipped`, `missing` and `failed`. A shadow replays nothing (`skipped`). A row for a platform that is not configured stays in place until a later boot ages it out.
|
|
44
|
+
|
|
45
|
+
Not covered: a hard kill (OOM, `SIGKILL`, the force-exit, WSL torn down) runs none of step 0, so it saves nothing.
|
|
46
|
+
|
|
33
47
|
## Safety mechanisms
|
|
34
48
|
|
|
35
49
|
| Mechanism | Behavior |
|
|
@@ -45,6 +59,7 @@ SIGTERM/SIGINT received
|
|
|
45
59
|
|
|
46
60
|
```ts
|
|
47
61
|
core.installSignalHandlers() // trap SIGTERM + SIGINT
|
|
62
|
+
core.onBeforeShutdown(() => {}) // sync, runs first (step 0), before the queue is cancelled
|
|
48
63
|
core.onShutdown(async () => {}) // register cleanup callback
|
|
49
64
|
await core.shutdown(timeoutMs?) // trigger shutdown manually (default 10s)
|
|
50
65
|
core.isShuttingDown // boolean
|
|
@@ -54,9 +69,10 @@ core.isShuttingDown // boolean
|
|
|
54
69
|
|
|
55
70
|
| Component | Method | What it does |
|
|
56
71
|
|-----------|--------|-------------|
|
|
72
|
+
| `MessageDebouncer` | `close(sink)` | Hands every waiting batch and parked message to `sink`, clears timers; later submits and re-flushes go to `sink` too |
|
|
57
73
|
| `TaskScheduler` | `stop()` | Clears the poll timer |
|
|
58
|
-
| `SpaceQueue` | `cancelAll()` | Drops all pending entries, returns count |
|
|
74
|
+
| `SpaceQueue` | `cancelAll(makeError)` | Drops all pending entries, rejects each with `makeError(spaceId)`, returns count |
|
|
59
75
|
| `SpaceQueue` | `waitForActive(ms)` | Resolves when active count hits 0 or timeout |
|
|
60
|
-
| `AgentContainerRunner` | `killAll()` | Kill all running containers via `docker kill` |
|
|
76
|
+
| `AgentContainerRunner` | `killAll()` | Kill all running containers via `docker kill`, reason `shutdown` |
|
|
61
77
|
| `Db` | `close()` | Closes SQLite connection |
|
|
62
78
|
| `WhatsAppBaileysAdapter` | `shutdown()` | Ends the Baileys socket |
|