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
@@ -1119,8 +1119,8 @@ Both are **host-side only** (`src/preflight/`, `src/env-dump/`, `src/cli/mercury
1119
1119
  `docker ps -q --filter label=mercury.managed=true` empty *and*
1120
1120
  `pgrep -f 'bin/pi '` empty, in a loop that waits — the `tasks` cron query does
1121
1121
  not show one-shot `at` verify runs, and a restart loses the dying process's
1122
- retry. Specs: `docs/archive/2026-09-07-doctor-delegates-to-preflight.md`,
1123
- `docs/archive/2026-09-07-env-dump.md`. **Both commits are on the box since the
1122
+ retry. Specs: `docs/archive/release-gate/2026-09-07-doctor-delegates-to-preflight.md`,
1123
+ `docs/archive/release-gate/2026-09-07-env-dump.md`. **Both commits are on the box since the
1124
1124
  2026-09-08 deploy** and released in 0.21.0; as of 2026-09-09 it runs `dev`
1125
1125
  `6bbb88d`, so the checks below are runnable now.
1126
1126
 
@@ -1363,7 +1363,7 @@ reason this sentence is here. Restore before the next sync.
1363
1363
 
1364
1364
  ## env-diff (R2.2) — live checks (added 2026-09-09, deploy-gated)
1365
1365
 
1366
- Goal `release-gate` R2.2, spec [`docs/archive/2026-09-09-env-diff.md`](archive/2026-09-09-env-diff.md).
1366
+ Goal `release-gate` R2.2, spec [`docs/archive/release-gate/2026-09-09-env-diff.md`](archive/release-gate/2026-09-09-env-diff.md).
1367
1367
  Host-side only: `src/env-dump/diff.ts` and one `src/cli/mercury.ts` subcommand,
1368
1368
  nothing under `container/Dockerfile`'s `COPY` list and no `package.json` version
1369
1369
  bump — **a restart deploys this, no image rebuild.** Every line below is run
@@ -1618,7 +1618,7 @@ box does not emit at `log_level: info` (D-009) — nothing below relies on it.
1618
1618
  - [x] MANUAL-VERIFY(2026-09-12): **`gnews:he:IL:659c944029:q` gains a `newestItemAt`, or is named as the one that never does.** Neither `gnews:12:q` nor `gnews:76:q` ever carried one on the box (the 2026-09-12 dump). Three ticks after `apply`, read the row: either the key has `newestItemAt` (the migration carried nothing because there was nothing, and the query has since served a dated item) or it still has none — in which case the Hebrew club query is answering 200 with zero items and that is the next finding to file, not this story's. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the key carries `newestItemAt` 2026-09-14 06:59Z, `failures=0` — the Hebrew club query serves dated items.)*
1619
1619
  - [x] MANUAL-VERIFY(2026-09-12): **Bluesky items parse on the box and one reaches a run.** Within a day: the Athletic FC key has `newestItemAt` within the last 24 h, and `journalctl … | grep 'opened a verify run'` shows a run whose `terms` include a covered club on a tick where the trace's item lines carry a `bsky.app/profile/…/post/…` link with a title that is the post's first line, not a URL. David Ornstein's key writes one `source silent` warn on the first tick past 7 days of its newest post (2026-09-01 at probe time) — expected, D-009. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: the Athletic FC key has `newestItemAt` 2026-09-14 19:44Z, inside 24 h, `failures=0`; Ornstein's newest is 2026-09-02 as D-009 expects. The second half — a `bsky.app/profile/…/post/…` line inside a verify trace — not read yet.)* *(2026-09-15: verify trace `mercury-1789278264459-2.jsonl` (09-13 05:44Z) carries `@theathleticfc.bsky.social … Yamal became the youngest player … | https://bsky.app/profile/theathleticfc.bsky.social/post/3mver…` — title is the post text — on a tick whose terms include ריאל מדריד / מכבי חיפה; Ornstein's `source silent` warn fired 2026-09-12 14:48Z.)*
1620
1620
  - [x] MANUAL-VERIFY(2026-09-12): **nothing invisible reaches the digest window file.** After the next 09:00 article, from `~/whatsapp-bot`: `~/.bun/bin/bun -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");console.log((s.match(/[\u{061C}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{FEFF}\u{E0000}-\u{E007F}]/gu)??[]).length)' ~/whatsapp-bot/.mercury/spaces/football-friends/knowledge/feed-digest.md` prints `0`. A `bun -e` one-liner on purpose: the shell's `tr` does not take these escapes and would count nothing while looking like it counted. Positive control on the same day: at least one Telegram item is in the window (a `t.me/` link), so the strip had something to strip through. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `feed-digest.md` (1,843,849 bytes) has 0 invisible carriers; positive control 231 `t.me/` links in the same file.)*
1621
- - [ ] MANUAL-VERIFY(2026-09-12): **an undated item renders `seen`, and the run reads it right.** The Maccabi TLV club feed (`rss:https://www.maccabi-tlv.co.il/feed`) has no dates. On the first tick its item matches (a `מכבי תל אביב` headline), the verify prompt in the trace carries `- seen YYYY-MM-DD HH:MM | Maccabi Tel Aviv official | …` and not `- ? |`; the run's reply either posts the item with the date it read on the page or answers `NO_UPDATE` — never with the `seen` time presented as a publication date. *(2026-09-15: no trace since 09-12 carries `Maccabi Tel Aviv official` — the feed has matched nothing yet; unfalsified.)*
1621
+ - [ ] MANUAL-VERIFY(2026-09-12): **an undated item renders `seen`, and the run reads it right.** The Maccabi TLV club feed (`rss:https://www.maccabi-tlv.co.il/feed`) has no dates. On the first tick its item matches (a `מכבי תל אביב` headline), the verify prompt in the trace carries `- seen YYYY-MM-DD HH:MM | Maccabi Tel Aviv official | …` and not `- ? |`; the run's reply either posts the item with the date it read on the page or answers `NO_UPDATE` — never with the `seen` time presented as a publication date. *(2026-09-15: no trace since 09-12 carries `Maccabi Tel Aviv official` — the feed has matched nothing yet; unfalsified.)* *(2026-09-25: the expected string cannot appear. For an RSS source the outlet is the feed's `<channel><title>`, which is `מועדון הכדורגל מכבי תל אביב` on the real feed today. `Maccabi Tel Aviv official` is only the profile's scorecard comment. Grep live traces for `| מועדון הכדורגל מכבי תל אביב |` instead. The shadow fixture rehearsed the mechanism (`- seen 2026-09-25 01:39 | מכבי תל אביב | …`, no `- ? |`, run `no_update`; see the S10 line below), but its channel title is not the real one, so this line still waits for a live match.)*
1622
1622
  - [x] MANUAL-VERIFY(2026-09-12): **the age-out reader has a number.** Over the first week: `journalctl --user -u mercury --since '-7d' | grep -c 'deferred items dropped unverified'`. Zero is a valid answer and means the `verify_max_items` trade never bit; a non-zero count is the measurement the bug's "M" half (priority for the deferred tail) waits for — read the `reason` per item (`ttl` is the age-out the bug describes, `cap` is a flood day evicting the tail through `BUFFER_CAP`). Either way, note the number in `docs/bugs/feed-watch-deferred-items-can-age-out-unverified.md`. *(2026-09-14 21:20 IDT, read from the box after the 20:47 restart: `grep -c 'deferred items dropped unverified'` over 7 days = **0**. Noted in the bug doc.)*
1623
1623
  - [ ] MANUAL-VERIFY(2026-09-12): **the eight exclude terms fire on real items and on nothing else.** Over the first week at `log_level: debug` for one hour on a match day, or from the digest window file otherwise: `grep 'dropped by an exclude term'` naming `How to watch`, `live stream`, `livestream`, `WATCHLIVE`, `Bet Builder`, `Betting Tips`, `פנטזי ליגת` or `NBA` — each `title` in those lines is a viewing guide, stream spam, a betting tip, the fantasy product or basketball. A title that is a real story is the silent direction and the term comes out the same day. *(2026-09-15: the box logs the drop at debug and runs at `log_level: info`, so the journal cannot show it; a shadow at debug level was tried — its first poll evaluates only items new since the snapshot (the `seen` state is copied), 0 drops in a 5-minute window. Needs a debug hour on the live box, or a bench snapshot that resets the poller's seen state.)*
1624
1624
 
@@ -1952,3 +1952,194 @@ and six config rows. Every line below is read on the box after `apply`.
1952
1952
  the 08:00 IDT post next morning is the one-line "אין חדש." form (the
1953
1953
  notebook is empty), and `task_runs` for the space shows one run/day
1954
1954
  with its cost.
1955
+
1956
+ ## Plan bugs-0915 / S1
1957
+
1958
+ > `shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout` — the route's
1959
+ > keepalive default moved from 15 s to 4 s (`DEFAULT_SHADOW_KEEPALIVE_MS`,
1960
+ > `src/core/routes/shadow.ts`), under `Bun.serve`'s 10 s `idleTimeout`, which
1961
+ > no site sets. Host file, not baked: restart-only.
1962
+
1963
+ - [x] MANUAL-VERIFY(2026-09-15): **a container-length simulate run streams to its `result`.** ✅ 2026-09-16 02:18 IDT: on the 0.23.2 shadow (`5f6c5c0`, ext image `9eedafd33b0d`) a 44.3 s group-mention run streamed 11 `: keepalive` comments (4 s cadence) after `event: entry` and closed with one `event: result` `"outcome":"settled"`; a second 6.4 s run the same; `grep -c 'request timed out after 10 seconds'` = 0 and `grep -c 'Shadow simulate client disconnected'` = 0 on the shadow journal. Cost $0.2847 over the 2 runs. After the deploy: `shadow snapshot && shadow up`, then the §"shadow-simulate-route (M2.2b)" `curl -sN` line above with a text that starts a container (`ויצבוט מה חדש לריאל?` did on 09-15). The stream carries `event: entry` (`typing`), then `: keepalive` comments roughly every 4 s, and closes with exactly one `event: result` whose data has `"outcome":"settled"`; `journalctl --user -u mercury-shadow --since '-5m' | grep -c 'request timed out after 10 seconds'` is 0 and `grep -c 'Shadow simulate client disconnected'` is 0. The 09-15 failure was a 12 s run (`elapsedMs=12003`), so any run past ~12 s is the proof. `shadow down` afterwards. **Cost: one container run on the shadow's model leg.**
1964
+
1965
+ ## Plan bugs-0915 / S2 — smoke admin fixture keyed the runtime's way (added 2026-09-15, deploy-gated: restart)
1966
+
1967
+ Supersedes the un-ticked R3.2 line *"the snapshot's admin row is the runner's,
1968
+ not the live bot's"* above — that line asked for the bare-jid row, which was
1969
+ the bug on disk. Fix: `src/smoke/cli.ts` promotes through `whatsAppCallerId`
1970
+ (`src/adapters/whatsapp-thread.ts`), the same function `readCallerRole` reads
1971
+ with. Post-mortem: `docs/debug/moderate/2026-09-15-smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member.md`.
1972
+
1973
+ - [x] MANUAL-VERIFY(2026-09-15): **the admin fixture runs as admin.** ✅ 2026-09-16 02:21 IDT: one `role: admin` fixture (`mentionBot: true`, `replied-within` + `not-a-refusal`) on the 02:06 snapshot, `mercury smoke --space football-friends --fixtures <file> --json` exit 0, `results[0].role` = `{"asked":"admin","expectedReadBack":"admin","ran":"admin"}`, verdict `pass`, 6.9 s; report `~/mercury-shadow/2026-09-15T23-06-24-715Z/.mercury/smoke/2026-09-15T23-21-18-017Z.json`. On a fresh
1974
+ snapshot, a fixture file with one `role: admin` fixture (`mentionBot: true`,
1975
+ `replied-within` + `not-a-refusal`), `cd ~/mercury-shadow/current && bun run
1976
+ <src>/src/cli/mercury.ts smoke --space football-friends --fixtures <file>
1977
+ --json`: `results[0].role` is
1978
+ `{"asked":"admin","expectedReadBack":"admin","ran":"admin"}` and the verdict
1979
+ is not failed on the role (`because` does not say *resolved the sender as
1980
+ "member"*).
1981
+ - [x] MANUAL-VERIFY(2026-09-15): **the snapshot's admin row is the runner's, keyed
1982
+ the way the runtime reads it.** ✅ 2026-09-16 02:22 IDT: the query on the shadow DB returns exactly `whatsapp:15550100002@s.whatsapp.net|admin|smoke` (one row, no bare-jid row, no `member` row); on `~/whatsapp-bot/.mercury/state.db` read-only it returns nothing. After that run, `sqlite3
1983
+ ~/mercury-shadow/current/.mercury/state.db "select platform_user_id, role,
1984
+ granted_by from space_roles where platform_user_id like '%15550100002%'"`
1985
+ returns exactly one row: `whatsapp:15550100002@s.whatsapp.net|admin|smoke`
1986
+ — no bare `15550100002@s.whatsapp.net` row, and no second `member` row
1987
+ beside it. The same query on `~/whatsapp-bot/.mercury/state.db`
1988
+ (`-readonly`) returns nothing.
1989
+ - [ ] MANUAL-VERIFY(2026-09-15): **the owner leg reads back admin.** A `role:
1990
+ owner` fixture on a snapshot taken with `--admins <the live owner's id>`
1991
+ reads back `ran: admin` whether the configured id is written
1992
+ `<digits>@s.whatsapp.net` or `whatsapp:<digits>@s.whatsapp.net` (the
1993
+ runner strips the prefix before sending; the runtime adds it back).
1994
+
1995
+ ## Plan bugs-0915 / S4 — shadow up refreshes the snapshot's credential (added 2026-09-16, deploy-gated: restart)
1996
+
1997
+ > `shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token` —
1998
+ > `shadow up` copies the live `global/auth.json` over the snapshot's at start and
1999
+ > `up --dry-run` refuses `credential-stale` when it cannot. Host CLI file, not baked:
2000
+ > restart-only (the CLI is read from `~/mercury-src` on each invocation, so the sync
2001
+ > alone is the deploy for this one).
2002
+
2003
+ - [x] MANUAL-VERIFY(2026-09-16): **`up --dry-run` says what it will do with the credential.** ✅ 2026-09-16 02:22 IDT (current form): `credential: current — same refresh token as the live file; up re-copies it at start regardless`, then `No refusals`. The `stale —` wording was not captured (see the next line). From `~/whatsapp-bot`: `~/.bun/bin/bun run ~/mercury-src/src/cli/mercury.ts shadow up --dry-run` prints a `credential:` line reading `current — same refresh token as the live file …` right after a fresh snapshot, and `stale — the live bot rotated its refresh token since the snapshot …` once the live journal has printed a `Rotated anthropic oauth refresh token` line newer than `SNAPSHOT.json`'s `takenAt`. Neither line carries a token or a fingerprint.
2004
+ - [x] MANUAL-VERIFY(2026-09-16): **a snapshot older than one live rotation runs a turn.** ✅ 2026-09-16 02:16 IDT by evidence, message not captured: snapshot 02:06:24, live `global/auth.json` rotated 02:15:26, `shadow up` (0.23.2 CLI) at 02:16:15 left the snapshot's `auth.json` byte-identical to the live file (`cmp`) with mtime 02:16:15, and the two simulate runs above settled on it. The `Credential: copied …` line itself scrolled past a `tail -10`; re-read it on the next rotated snapshot. The 09-15 repro: take a snapshot, wait for the live `Rotated …` line (or use a snapshot from before the last one), then `shadow up` — it prints `Credential: copied the live auth.json into the snapshot (the live bot had rotated since the snapshot).`, `journalctl --user -u mercury-shadow --since '-2m' | grep -c 'credential.first-leg'` is 0, and one `POST /api/shadow/simulate` that starts a container returns a reply that is not the generic failure line. `shadow down` afterwards. **Cost: one container run on the shadow's model leg.**
2005
+ - [ ] MANUAL-VERIFY(2026-09-16): **the refusal names its remedy.** With the live unit stopped and a snapshot whose `SNAPSHOT.json.source.projectDir` points at a directory that has no `.mercury/global/auth.json` (a scratch copy of the project dir with that one file removed), `shadow up --dry-run` lists `[credential-stale]` with the snapshot path, the live path, and `mercury auth login` in the message, and starts nothing. Restore the unit afterwards.
2006
+
2007
+ ## Deploy 2026-09-16 — `5f6c5c0` (v0.23.2) on the box; image rebuild; plan bugs-0915 landed
2008
+
2009
+ The box ran `f436b04` on the 0.23.0-beta.2 image; `dev` had gained 38 commits
2010
+ — the four bugs-0915 fixes (S1 simulate-stream keepalive, S2 smoke admin
2011
+ fixture, S3 bridges group-vs-DM, S4 shadow `up` credential refresh), the
2012
+ dependabot bumps + chat 4.40.0 shim (#101–#106, #111), the
2013
+ `tel-aviv-flat-search` profile, and `main`'s 0.23.2 release bump. No baked file
2014
+ changed, but `package.json`'s `version` did — the 2026-09-09 gotcha again — so
2015
+ this was an image rebuild, not a restart. A candidate shadow **before** the
2016
+ restart was impossible by design: `shadow up` refused with *the derived agent
2017
+ image mercury-agent-ext:9eedafd33b0d is not on this host, and a shadow never
2018
+ builds one*, and the `POST /prebuild-ext-image` remedy that message names does
2019
+ not exist as a route (bug to file). The candidate shadow therefore ran
2020
+ **after** the restart, on the derived image the live boot built.
2021
+
2022
+ ```
2023
+ Classes (Gate 1): image + code + profile + docs — diff f436b04..5f6c5c0: package.json "version" 0.23.0-beta.2 → 0.23.2 (image class; no Dockerfile COPY source touched); src/{adapters/whatsapp-thread,bridges/{slack,teams,telegram},chat-shim,cli/mercury,core/routes/shadow,core/simulate-ingress,ops/{shadow-service,shadow-snapshot},smoke/cli}.ts (code); examples/profiles/tel-aviv-flat-search/** (profile — `check: clean` on the box); bun.lock + package.json deps (bun install ran: chat 4.40.0, baileys rc14, zod 4.6.2, cron-parser 5.10.0, axios 1.20.0, imapflow 2.0.2)
2024
+ Deploy needs: image rebuild (package.json version bump; `container/build.sh` from ~/projects/mercury → mercury-agent:0.23.2 = d9cf88f03d07, label com.mercury.version=0.23.2; `bun run smoke:image mercury-agent:0.23.2` 4/4 ✓) + restart (derived image followed: mercury-agent-ext:9eedafd33b0d built in 211 s, 42c51280d035 pruned)
2025
+ Shadow (Gate 2): snapshot ~/mercury-shadow/2026-09-15T23-06-24-715Z (quick_check ok, 6.0 MB db), source commit 5f6c5c03 (post-restart, `--src ~/mercury-src`), preflight ~/mercury-shadow/2026-09-15T23-06-24-715Z/.mercury/preflight/2026-09-15T23-16-14-591Z.json exit 0 (9 pass · 1 warn host.timezone · 0 blocked · 2 skipped, effective image mercury-agent-ext:9eedafd33b0d), smoke ~/mercury-shadow/2026-09-15T23-06-24-715Z/.mercury/smoke/2026-09-15T23-21-18-017Z.json 1/1 pass (admin fixture, 6.9 s), two simulate turns settled (6.4 s / 44.3 s, 11 keepalives on the long one), down: $0.2847 over 2 run(s)
2026
+ Rollback rehearsed (5): shadow on f436b047 (`--src ~/mercury-src` before the sync) with `mercury-agent:latest` re-tagged to 0.23.0-beta.2 (c3c6976b8cea) for the run, preflight ~/mercury-shadow/2026-09-15T23-06-24-715Z/.mercury/preflight/2026-09-15T23-07-31-335Z.json exit 0 (9 pass · 1 warn · 0 blocked · 2 skipped, mrctl-roundtrip pass), down: $0 over 0 runs. Rollback = `docker tag mercury-agent:0.23.0-beta.2 mercury-agent:latest && ~/sync-mercury.sh` from a checkout at f436b04 (the beta.2 image and derived 42c51280d035 — the derived one was pruned by the new boot; it rebuilds on the first run)
2027
+ Gap (Gate 9): shadow up --dry-run: No refusals (02:07 and 02:10), docker ps: empty, pgrep 'bin/pi ': empty, task_runs in flight: 0, next cron 08:00 IDT (task 825), at 02:10:04 IDT
2028
+ Restart: 02:10:31 IDT; journal: Agent image resolved image=mercury-agent:latest imageId=sha256:d9cf88f03d07…; boot preflight 0.23.2 image.contract ✅ 8 pass · 1 warn · 0 blocked · 3 skipped; WhatsApp connection open 02:10:36; Built derived agent image mercury-agent-ext:9eedafd33b0d 02:14:04; unit file unchanged (diffed)
2029
+ Inside the artifact (3): live preflight ~/whatsapp-bot/.mercury/preflight/2026-09-15T23-14-32-282Z.json exit 0 (9 pass · 1 warn host.timezone[814@main] · 0 blocked · 2 skipped, effective mercury-agent-ext:9eedafd33b0d); host `mercury --version` 0.23.2 = label on mercury-agent:latest = label on mercury-agent-ext:9eedafd33b0d; process argv `bun run /home/guysaha/mercury-src/src/cli/mercury.ts run` with ~/mercury-src @ 5f6c5c0; grep DEFAULT_SHADOW_KEEPALIVE_MS src/core/routes/shadow.ts = 4_000 (S1), grep 'whatsapp:' src/smoke/cli.ts = 3 (S2), slack.ts isDM = `ch.startsWith("D")` only (S3), grep credential-stale src/ops/shadow-service.ts ≥ 1 (S4) — all on the mirror the process runs from
2030
+ Real turn (Gate 5): live preflight container.mrctl-roundtrip pass; `mercury chat -s admin --json` 200 in 13.4 s, non-empty reply (token_usage row at 23:15:39Z)
2031
+ Reader (Gate 10): host.heartbeat pass (2 tasks); last ping taskId=621 status=200 at 19:01:24Z (pre-restart); the next reader is the 08:00 IDT task 825 / 09:00 article
2032
+ Manual (Gate 12): 5 lines ticked above (S1 ×1, S2 ×2, S4 ×2); 2 left open — S2 "owner leg reads back admin" (needs a `--admins` snapshot) and S4 "the refusal names its remedy" (needs the live unit stopped)
2033
+ Check gate: ~/check-mercury.sh check on 5f6c5c0: all 12 links passed, 5684 pass / 0 fail
2034
+ Space profiles: _template n/a; tel-aviv-flat-search clean; football-reporter 1 drift — `feed-watch.max_per_hour` is `1` (set by dashboard), profile wants `3` — pre-existing, owner-set, left alone
2035
+ ```
2036
+
2037
+ ## Box config 2026-09-16 — `scheduling.default_timezone` + task 850 re-zoned (72 h log review, restart-gated for the yaml)
2038
+
2039
+ - [ ] MANUAL-VERIFY(2026-09-16): **task 850 fires at 12:00 IDT on Sun–Thu after the DB edit.** Row 850 was changed in place (`cron` `0 9 * * 0-4`/`timezone` NULL → `0 12 * * 0-4`/`Asia/Jerusalem`, `next_run_at` untouched = 2026-09-17 09:00Z). The 2026-09-17 12:00 IDT reminder lands in main (`task_runs` row for 850, `ok`), and the following `next_run_at` reads 2026-09-20 09:00Z. **Re-check after 2026-10-25** (clocks change): the run must still be at 12:00 local, i.e. 10:00Z.
2040
+ - [ ] MANUAL-VERIFY(2026-09-16): **`scheduling.default_timezone: Asia/Jerusalem` is picked up on the next restart.** After the first restart following 2026-09-16 17:40 IDT, `mercury preflight` no longer warns `host.timezone … active cron task(s) have no timezone` (0 zoneless rows: `SELECT id FROM tasks WHERE active=1 AND cron IS NOT NULL AND timezone IS NULL` is empty), and a `mrctl tasks create --cron "0 8 * * *"` from any space without `--timezone` stores `Asia/Jerusalem`. Idea `task-creation-defaults-timezone-from-the-space` is the durable fix; this yaml key is the stopgap.
2041
+
2042
+ ## Profile notes from the 2026-09-16 72 h log review (applied live 2026-09-16, apply-only) + poster installed (restart-gated)
2043
+
2044
+ Four `AGENTS.md` sentences, each from one failure in the window; `apply` wrote the
2045
+ two live files and `check` is clean. `feed-watch.max_per_hour: "1"` was adopted
2046
+ into the football `config.yaml` (owner's dashboard value since 09-16 00:59) so
2047
+ `apply` no longer offers to revert it.
2048
+
2049
+ - [ ] MANUAL-VERIFY(2026-09-16): **comebacks are creative, not echoes — and nothing is off-limits.** Owner decision 2026-09-16: no restriction on slurs or targets; the bar is whether it is funny. In the football group, a member throws "יא הומו" at the bot; the reply is a comeback that says something about the member or the club, not the same word bounced back (09-15: "יא הומו בעצמך" ×2), and the bot never refuses or lectures on the topic.
2050
+ - [ ] MANUAL-VERIFY(2026-09-16): **"דרבי" = same city, nothing else.** The next article or scan that covers Maccabi Haifa vs a non-Haifa side does not use the word even when the source does; the next same-city pair (any city, no list) does.
2051
+ - [ ] MANUAL-VERIFY(2026-09-16): **home/away and venue come from a club page.** In the trace of the next scan or article that says בית / חוץ / מארחת or names a stadium, a `curl`/pinchtab fetch of the club's or league's fixture page precedes the sentence, and the club's `knowledge/episodes/<club>.md` `## Current State` carries the venue line with that source; two runs the same day agree.
2052
+ - [ ] MANUAL-VERIFY(2026-09-16): **a corrected sourced fact shows its evidence first.** In tel-aviv-flat-search, when a member contradicts a price/field the bot read from a listing, the reply's first half-line says what the page showed ("ביד2 כתוב …") before the card is corrected. (09-16 #5608: the yad2 page did show 12,000 struck through; the bot deleted the fact silently.)
2053
+ - [ ] MANUAL-VERIFY(2026-09-16): **`poster` is live after the next restart.** `mercury add` copied `examples/extensions/poster` to `~/whatsapp-bot/.mercury/extensions/poster/` (installed copy == source, skill under `global/skills/poster`). After the next restart in a gap: the journal prints `Building derived agent image` → `Built derived agent image` on the first run, `docker run --rm --entrypoint sh mercury-agent-ext:<new> -c 'cat /usr/local/bin/poster'` shows the shim, and an owner "תעשה לי פוסטר …" in the football test group returns an image with correct Hebrew. Needs `MERCURY_POSTER_GEMINI_KEY` in `.env` first (layer 1); without it `poster render`/`compose` still work and `poster art` fails naming the key. Ticks L1272's image leg too.
2054
+
2055
+ ## Plan p0-bugs-0922 / S1 — storage cleanup clears attachment refs per deleted file (added 2026-09-22, deploy-gated: restart)
2056
+
2057
+ - [ ] MANUAL-VERIFY(2026-09-22): **one aged-out inbox file no longer wipes the space's photos.** After the next restart, on the first `_builtin:storage-cleanup` run that prints `Storage cleanup complete`, `attachmentsNullified` is at most the inbox share of `filesDeleted` (2026-09-14 read `filesDeleted=3 … attachmentsNullified=49`). Then, in a space that got an image today: `SELECT id, attachments FROM messages WHERE space_id='<space>' AND created_at > <yesterday ms>` still shows today's refs. Rows nulled before this deploy stay NULL; nothing restores them.
2058
+
2059
+ ## Plan p0-bugs-0922 / S2
2060
+
2061
+ - [ ] MANUAL-VERIFY(2026-09-22): **the image's system prompt no longer offers URLs to `read`.** After the rebuild (`container/build.sh`, then restart in a gap): `docker run --rm --entrypoint sh mercury-agent-ext:<new> -c 'grep -c "cannot open a URL" /app/src/agent/container-entry.ts; grep -c http $(find / -path "*pi-coding-agent/dist/core/tools/read.js" 2>/dev/null | head -1)'` prints `1` then `0` (the second `0` confirms the image's `read` still has no URL branch, since the image installs pi without a lockfile). **Image leg ✅ 2026-09-22 17:03 IDT on `mercury-agent-ext:7e46598c580c`: `1`, `0`; the 72 h trace leg is still open.** Then, over the next 72 h of football-friends runs on `claude-sonnet-5`, no trace has `tool_execution_end … read … ENOENT … /https:/`.
2062
+
2063
+ ## Plan p0-bugs-0922 / S3 — subagent failure verdict shared by all three modes (added 2026-09-22, deploy-gated: rebuild)
2064
+
2065
+ > `subagent-parallel-mode-reports-api-errors-as-completed` — single, chain and
2066
+ > parallel now judge a run through one `isSubagentError` (`resources/pi-extensions/subagent/outcome.ts`),
2067
+ > so a leg where `pi --mode json` exits 0 with `stopReason: "error"` counts as
2068
+ > failed in the summary text and in the ✓/✗ display. `resources/` is baked into
2069
+ > the image: a restart alone deploys nothing.
2070
+
2071
+ - [x] MANUAL-VERIFY(2026-09-22): **the image carries the shared predicate.** ✅ 2026-09-22 17:03 IDT: base `mercury-agent:0.23.2` = `112083cf7753` (built 16:57 from `cce3433`), derived `mercury-agent-ext:7e46598c580c` built 17:02:40; `outcome.ts` listed, `isSubagentError` count 12. After `bash ./container/build.sh` and a restart, once the journal prints `Built derived agent image`: `docker run --rm --entrypoint sh mercury-agent-ext:<new> -c 'ls /app/resources/pi-extensions/subagent/outcome.ts && grep -c isSubagentError /app/resources/pi-extensions/subagent/index.ts'` lists the file and prints 12 (0 = the old image).
2072
+ - [ ] MANUAL-VERIFY(2026-09-22): **a parallel subagent call reports honestly.** In the trace of the next run that uses `subagent` with `tasks: [...]`, the tool result's first line `Parallel: k/N succeeded` equals the number of legs whose `[agent]` line says `completed`; any leg with `stopReason: "error"` reads `failed: <error text>`, never `completed: (no output)`.
2073
+
2074
+ ## Plan p0-bugs-0922 / S4 — strict boolean parser for four default(true) keys (added 2026-09-22, deploy-gated: restart)
2075
+
2076
+ - [x] MANUAL-VERIFY(2026-09-22): **a blank or typo'd boolean no longer switches a feature off.** ✅ 2026-09-22 17:04 IDT: restart 16:58:55 booted clean (preflight 9 pass / 0 warn / 0 blocked, no config error in the journal); `loadConfig()` from `~/whatsapp-bot` reads `telegramFormatEnabled`, `mediaEnabled`, `taskNotifyOnFailure`, `alertsEnabled` all `true`. After the restart, `mercury service logs` shows a normal boot (no `alerts.enabled (MERCURY_ALERTS_ENABLED) must be …` or sibling config error), and the dashboard / `mercury status` still reports alerts, media and Telegram formatting on.
2077
+
2078
+ ## pi 0.87.1 + Claude Opus 5.5 (added 2026-09-24, deploy-gated: rebuild + host pi — deployed 16:03 IDT on branch `chore/pi-0.87-opus-5-5`)
2079
+
2080
+ - [x] MANUAL-VERIFY(2026-09-24): **Opus 5.5 from WhatsApp.** In the owner's DM with the bot: `/model list` shows `[6] anthropic / claude-opus-5-5`; `/model switch 6`, one real question, then `/model switch 1`. Pass: a normal reply, and `SELECT model FROM token_usage WHERE space_id='main' ORDER BY id DESC LIMIT 2` shows `claude-opus-5-5` then (after the next message) `claude-opus-5`. — **Verified 2026-09-24 17:56–17:59 IDT**: owner's DM got the `/model list` reply, `Switched to anthropic / claude-opus-5-5.`, a normal Hebrew answer at 17:58:50 (token_usage row 2855, `main`, `claude-opus-5-5`; trace `mercury-1790261911497-14.jsonl` assistant `claude-opus-5-5` stopReason `stop`, 0 tool errors), then `Switched to anthropic / claude-opus-5.`; `main`'s `model.active` is back to `anthropic:claude-opus-5`.
2081
+ - [ ] MANUAL-VERIFY(2026-09-24): **the 19:00 and 21:00 football runs are unchanged.** Tasks 621 (19:00) and 699 (21:00) end `Scheduled run finished … status=ok outcome=posted model=claude-opus-5` in the journal — the first scheduled runs on pi 0.87.1.
2082
+
2083
+ ## Plan bench-patterns-0915 / S10 — feed-watch serves fixture files in shadow mode (added 2026-09-24, deploy-gated: restart)
2084
+
2085
+ > `shadow-feed-fixtures` (rehearsal-bench M1.7). A shadow serves
2086
+ > `<dataDir>/fixtures/feeds/<encoded target key>` in place of
2087
+ > that target's network fetch; the checked-in set and its copy command are in
2088
+ > `tests/fixtures/feed-watch/shadow/README.md`. Host-only extension code, not
2089
+ > baked: a restart deploys it, and the check is against the **installed** copy.
2090
+ > Fixtures make these older lines runnable, not passed — each still wants its own
2091
+ > evidence: *the leads rule survives contact* (`telegram%3ABeitarNews`),
2092
+ > *a highlights video is one line* (the YouTube Real Madrid file), *a hyphenated
2093
+ > women's headline is dropped* (`telegram%3Amanunitedisraell`), *an undated item
2094
+ > renders `seen`* (the Maccabi TLV file), *the eight exclude terms fire* (the BBC
2095
+ > file), *the next correction has no preface* (the one.co.il template), and both
2096
+ > *re-post of an unchanged fact* lines (the Guardian template).
2097
+
2098
+ - [ ] MANUAL-VERIFY(2026-09-24): **the live bot stays inert.** After the restart, `grep -c FIXTURE_SUBDIR ~/whatsapp-bot/.mercury/extensions/feed-watch/fixtures.ts` is at least 1, and a day later `journalctl --user -u mercury | grep -c 'shadow fixtures active\|serving fixture'` is 0. *(First half ✅ 2026-09-25 01:59 IDT, restart onto `b9d9a4e`: the installed copy's count is 2. The day-later journal count is still open; read it after 2026-09-26 02:00 IDT.)*
2099
+ - [x] MANUAL-VERIFY(2026-09-24): **a fixture drives a real verify run on a shadow.** Fresh snapshot, `shadow up`, wait for `shadow fixtures active`, copy in the Maccabi TLV file (README command); within one poll the shadow journal shows `serving fixture source=rss:https://www.maccabi-tlv.co.il/feed` then a verify task opened, and its trace carries `- seen … | Maccabi Tel Aviv official | …` (which is the undated-item line's own evidence); then `shadow down`, live uptime unbroken. *✅ 2026-09-25 01:36–01:55 IDT on snapshot `2026-09-24T22-36-12-855Z`, candidate `b9d9a4e`. The snapshot's own `feed-watch.quiet_hours` (00:30–07:30) was deleted in the **copy's** DB first; inside quiet hours the batch only folds into the digest, which shadow 1 showed. `shadow fixtures active files=0`, fixture copied in, then `serving fixture` at 22:39:33Z. The verify run did **not** open "within one poll": `batch_minutes: 15` holds the batch, and it opened on the 22:54:32 tick (`opened a verify run … taskId=1145 items=2`). The run ended `ok`/`no_update` in 13.9 s for $0.2448. Trace `mercury-1790290478389-1.jsonl` carries `- seen 2026-09-25 01:39 | מכבי תל אביב | מכבי תל אביב: מכירת הכרטיסים …` and no `- ? |` line. **The expected outlet string is wrong**: `Maccabi Tel Aviv official` is a scorecard comment in `football-reporter/config.yaml`, not a configured `outlet`. For RSS the outlet is the feed's own `<channel><title>`, which is `מכבי תל אביב` in the fixture and `מועדון הכדורגל מכבי תל אביב` on the real feed today, so the same string in L1621 and the fixture README can never match. Live uptime was unbroken (35,715 s at the end of the rehearsal).*
2100
+
2101
+ ## Plan p0-plan-0922 / S6 — interrupted scheduled runs are ledgered (added 2026-09-24, deploy-gated: rebuild + schema migration)
2102
+
2103
+ > `ledger-misses-interrupted-runs` — every scheduled run writes its `task_runs`
2104
+ > row when it starts; a boot pass closes rows a previous process left `running`
2105
+ > as `interrupted`, and a graceful restart records its cut-off run the same way.
2106
+ > The first boot rebuilds `task_runs` (finished_at loses NOT NULL). `src/types.ts`
2107
+ > is baked: rebuild the image, then restart in a gap. Rollback needs
2108
+ > `DELETE FROM task_runs WHERE finished_at IS NULL` first. The §4 evidence
2109
+ > block goes under `## Deploy evidence` at the end of
2110
+ > `docs/archive/ops/2026-09-24-ledger-misses-interrupted-runs.md`.
2111
+
2112
+ - [x] MANUAL-VERIFY(2026-09-24): **rehearse on a shadow first.** Snapshot, `shadow up`, trigger a task, `shadow down` while it runs, `shadow up` again: the run's row reads `interrupted` and the second boot's journal names it in a `Reconciled interrupted work` line. *✅ 2026-09-25 01:56 IDT, snapshot `2026-09-24T22-36-12-855Z`, candidate `b9d9a4e`, as two legs, because the line as written cannot produce both halves. **Graceful**: task 850 was made due in the copy's DB (a shadow has no admins, so `POST /api/tasks/850/run` answers 403 `tasks.create`). `shadow down` mid-run closed row 1263 `interrupted` itself (`Scheduled run interrupted by shutdown … runId=1263`), so the second boot had nothing left: `interrupted={"task_run":0}`. **Hard**: `systemctl --user kill -s SIGKILL mercury-shadow` mid-run left row 1264 `running`. The next boot logged `Reconciled interrupted work … kind=task_run id=1264` with `interrupted={"task_run":1}`, and the orphaned container was cleaned up. Full block: the archive doc's `## Deploy evidence`.*
2113
+ - [x] MANUAL-VERIFY(2026-09-24): **the rollback works before the deploy needs it.** On the rehearsal shadow after the migrated boot, run the rollback `DELETE FROM task_runs WHERE finished_at IS NULL`, then start it on the previous sha (release-gate gate 5): it boots and the dashboard's run history loads. Separately, just before the live restart, copy `~/whatsapp-bot/state.db` with `VACUUM INTO` to `~/db-backups/`. *✅ 2026-09-25 01:57 IDT: the DELETE removed 1 row (1264) on the migrated copy. The shadow on `62e1a718` booted, preflight exit 0 (10/0/0/2), and `/dashboard/page/tasks/850/runs` (with `HX-Request: true`) returned HTTP 200, with the `interrupted` row 1263 rendered as a plain row. Backup 01:59:07 IDT: `~/db-backups/2026-09-25-s6-s10-pre-deploy/state.db`, quick_check ok. **Path correction**: the live DB is `~/whatsapp-bot/.mercury/state.db`, not `~/whatsapp-bot/state.db`.*
2114
+ - [x] MANUAL-VERIFY(2026-09-24): **the migration ran and kept every row.** After the deploy restart, the journal has `Boot reconciliation finished`, and `PRAGMA table_info(task_runs)` on the live `state.db` shows `finished_at` with notnull 0 while `SELECT COUNT(*), MAX(id) FROM task_runs` matches what it printed before the restart. *✅ 2026-09-25 01:59 IDT: before the restart 1215 / 1261, after it 1215 / 1261, notnull 0, `Boot reconciliation finished … interrupted={"task_run":0}` at 22:59:10Z.*
2115
+ - [x] MANUAL-VERIFY(2026-09-24): **the image carries the widened type.** Once the journal prints `Built derived agent image`, `docker run --rm --entrypoint sh mercury-agent-ext:<new> -c 'grep -c TaskRunRowStatus /app/src/types.ts'` prints at least 1 (0 = the old image). *✅ 2026-09-25 02:02 IDT: `Built derived agent image mercury-agent-ext:bbe407d90b1b` (205.7 s) on base `mercury-agent:0.23.2` = `75edbe13c8b0`, the count is 2, label 0.23.2, and `smoke:image` passed 4/4.*
2116
+ - [ ] MANUAL-VERIFY(2026-09-24): **a restart mid-run shows as interrupted, not failed.** Restart while a scheduled task is running; its row in the dashboard's run history shows an amber `interrupted` badge, no "task failed" message reaches the group, and the creator's report says "interrupted by a restart".
2117
+ - [ ] MANUAL-VERIFY(2026-09-24): **the dashboard badges render.** Open the dashboard Tasks page during a scheduled run: the task's Last Run cell shows a blue `running` badge, and its ☰ history shows the same row with `—` under Took.
2118
+
2119
+ ## Plan p0-plan-0922 / S7 — a restart leaves no dangling ⏳ and no silent drop (added 2026-09-25, deploy-gated: restart)
2120
+
2121
+ > `orphaned-run-recovery` — a chat message accepted into the queue gets a
2122
+ > `chat_runs` row, deleted once the user is told something; a run cut off by a
2123
+ > restart or a crash is answered by the next boot ("please send it again", ⏳
2124
+ > deleted). A second process on the same data dir now refuses with exit 78.
2125
+ > No baked file changed: restart-only, in a gap (no `mercury.managed` container,
2126
+ > no `bin/pi`). New table is additive; rollback leaves it inert.
2127
+
2128
+ - [x] MANUAL-VERIFY(2026-09-25): Run a shadow beside the live bot and stop it mid-run — it must start with its own lock, and on its next start the log must show the interrupted run reconciled and "No bridge for interrupted chat runs". *(2026-09-25 03:36–03:40 IDT, snapshot `2026-09-25T00-36-06-428Z`, shadow on an exact tree of `dev` @ `c914945`, driven by `POST /api/shadow/simulate` into `main`'s DM: the shadow wrote `mercury.instance.lock` + `.json` (pid, port 8788, 0.23.2) and booted `interrupted={"task_run":0,"chat_run":0}`. Graceful leg: `shadow down` mid-run → "Run interrupted by shutdown; left open for the next boot … chatRunId=1", only a `typing` event in the outbox (no "Stopped current run"); next boot `interrupted={"task_run":0,"chat_run":1}`, "No bridge for interrupted chat runs … threads=1 runs=1", row kept with `notify_attempts=1`. Hard leg: SIGKILL of `mercury-shadow` mid-run on a second message → the next start took the lock, "Cleaned up orphaned containers count=1", both rows reconciled, "No bridge … threads=1 runs=2". The sent notice itself cannot appear on a shadow (no adapter) — the two live lines below prove it.)*
2129
+ - [x] MANUAL-VERIFY(2026-09-25): Start a second Mercury on another port against the live bot's data dir — it must exit at once with code 78 and name the live bot's PID, and the live bot must keep answering. *(Proven on the shadow 2026-09-25 03:37 IDT: `MERCURY_SHADOW=1 MERCURY_PORT=8799 bun src/main.ts` in the shadow project → exit 78, "Another Mercury process holds this data dir; refusing to start … holder={"pid":507915,…,"port":8788}", 8799 never bound, the shadow kept serving. Live 2026-09-25 03:43 IDT after the deploy: `MERCURY_PORT=8799` from `~/whatsapp-bot` with an empty scratch WhatsApp auth dir → exit 78, holder pid 611514 (the live `main.ts`) named, 8799 never bound, the live bot answered HTTP and WhatsApp did not reconnect.)*
2130
+ - [x] MANUAL-VERIFY(2026-09-25): Restart the bot while it is answering you in WhatsApp — the ⏳ should disappear and a "please send it again" message should arrive once it is back. *(Live 2026-09-25 18:15–18:16 UTC, one round covering both lines: the owner sent a long question then "thanks" 5 s later (debounced behind the in-flight flush); `systemctl --user restart mercury` at 18:16:04 mid-run, gap otherwise empty (only the owner's container, no task run). Shutdown: "Run interrupted by shutdown; left open for the next boot … chatRunId=15". Boot `interrupted={"task_run":0,"chat_run":2}`, both rows reconciled (id 15 with `statusMessageId`, id 16), "Interrupted chat run notified … count=2", "threads=1 notified=1 failed=0"; one outbound "⚠️ I was restarted before I could answer your last 2 messages … Please send them again."; `chat_runs` empty afterwards. Owner confirmed on the phone: the "⏳ Processing…" message was gone and exactly one notice arrived.)*
2131
+ - [x] MANUAL-VERIFY(2026-09-25): Send two messages quickly and restart during the first — only ONE "please send it again" message should arrive, mentioning 2 messages. *(Live 2026-09-25 18:15–18:16 UTC, one round covering both lines: the owner sent a long question then "thanks" 5 s later (debounced behind the in-flight flush); `systemctl --user restart mercury` at 18:16:04 mid-run, gap otherwise empty (only the owner's container, no task run). Shutdown: "Run interrupted by shutdown; left open for the next boot … chatRunId=15". Boot `interrupted={"task_run":0,"chat_run":2}`, both rows reconciled (id 15 with `statusMessageId`, id 16), "Interrupted chat run notified … count=2", "threads=1 notified=1 failed=0"; one outbound "⚠️ I was restarted before I could answer your last 2 messages … Please send them again."; `chat_runs` empty afterwards. Owner confirmed on the phone: the "⏳ Processing…" message was gone and exactly one notice arrived.)*
2132
+
2133
+ ## Plan p0-plan-0922 / S8 — debounced messages survive a restart (added 2026-09-26, deploy-gated: restart)
2134
+
2135
+ > `wire-debounce-flush-to-shutdown-hook` — messages still in a debounce batch,
2136
+ > or parked behind an in-flight run, are saved to `pending_ingress` at
2137
+ > shutdown step 0 and replayed by the next boot through the live path (15 min
2138
+ > cutoff). Deployed 2026-09-26 17:26 IDT as `dev` @ `2f8b875`, restart-only,
2139
+ > in a gap. Second restart 17:27 IDT on the new code: step 0 ran first
2140
+ > (`saved pending ingress … messages=0 rows=0 lost=0 elapsedMs=1`), boot
2141
+ > `interrupted={…,"pending_ingress":0}`, `Boot: pending ingress replayed=0 …
2142
+ > failed=0`, preflight 9/0/0/3, WhatsApp open. New table is additive;
2143
+ > rollback leaves it inert.
2144
+
2145
+ - [ ] MANUAL-VERIFY(2026-09-25): On a test space with a 10-second debounce, send a message and restart the bot inside that window, then confirm the journal shows "saved pending ingress" with `messages=1` followed by "Boot: pending ingress … replayed=1" and the chat gets its answer.
@@ -8,3 +8,4 @@
8
8
  | Q2 | S3 | Lane: does Avishai Tsabari own `src/core/conversation.ts` (1 commit in 14d — `0f546a5` contextMode default, 2026-09-07)? | ours / theirs / split: … | ours | 2026-09-15 (owner, via the adversarial review) |
9
9
  | Q3 | — | Lane: does Avishai Tsabari own `src/main.ts` (5 commits in 14d, latest `2bbe68f`; `Me` has 10)? Moot — S1's fix no longer touches the file. | ours / theirs / split: … | ours | 2026-09-15 (owner, via the adversarial review) |
10
10
  | Q4 | S2 | Lane: does Avishai Tsabari own `tests/fixtures/release-gate/regression-map.json` (1 commit in 14d — `974f042` allowlist, 2026-09-14)? S2's post-mortem may add one allowlist entry. | ours / theirs / split: … | ours | 2026-09-15 (owner, via the adversarial review) |
11
+ | Q5 | S4 | Lane: does Avishai Tsabari own `src/ops/` (3 files in 14d — `871f93b` shadow up/down/status/logs, 2026-09-07; `9e8268c` resolve the live project from the unit, 2026-09-11 — plus `tests/shadow-service.test.ts`)? S4 edits `shadow-snapshot.ts`, `shadow-service.ts` and that test. | ours / theirs / split: … | ours — co-owner of the tree, not a lane; no branch or PR in flight in `src/ops/` | 2026-09-15 (owner) |
@@ -1,7 +1,7 @@
1
1
  # Plan: bugs-0915
2
2
 
3
- **Status**: Active
4
- **Created**: 2026-09-15 **Last updated**: 2026-09-15
3
+ **Status**: Archived
4
+ **Created**: 2026-09-15 **Last updated**: 2026-09-16
5
5
  **Landing mode**: pr
6
6
  **Max width**: 4
7
7
  **Inputs**: bugs-0915 shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member slack-and-teams-bridges-report-a-group-as-a-dm shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token
@@ -0,0 +1,56 @@
1
+ ## Launched — 2026-09-15T17:52Z
2
+ Branch: worktree-fix-shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout
3
+
4
+ ## Report — 2026-09-15T21:51Z
5
+ Outcome: delivered
6
+ Delivered: pushed
7
+ Blocked-on: —
8
+ Deploy needs: restart
9
+ Files changed:
10
+ - docs/debug/moderate/2026-09-16-shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout.md
11
+ - docs/debug/shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout.md
12
+ - docs/pending-verification.md
13
+ - src/core/routes/shadow.ts
14
+ - tests/shadow-route.test.ts
15
+ Siblings must know:
16
+ - `src/core/routes/shadow.ts` now exports `DEFAULT_SHADOW_KEEPALIVE_MS` and `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS`; `tests/shadow-route.test.ts` reads `src/main.ts` and `src/smoke/boot.ts` read-only — a site that sets `idleTimeout` below 10 s turns it red.
17
+ - `docs/pending-verification.md` gained a `## Plan bugs-0915 / S1` heading at the end of the file; append yours below it, not inside it.
18
+ - The push carried S2's unpushed promotion commit `2575eb2` to `origin/dev` (it was ahead on local `dev`).
19
+ --- final report (verbatim) ---
20
+ ~~~
21
+ S1 — shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout — DELIVERED: pushed (dev 9bf98e7)
22
+
23
+ Fixed: the `/api/shadow/simulate` SSE stream sent `: keepalive` every 15 s
24
+ while `Bun.serve` (idleTimeout unset, default 10 s) closed any 10 s-silent
25
+ connection, so every container-length run lost its client before `result`.
26
+ - src/core/routes/shadow.ts — `DEFAULT_SHADOW_KEEPALIVE_MS = 4_000` (default
27
+ for `keepaliveMs`, was inline 15_000) and `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS
28
+ = 10_000`, exported + documented; option doc corrected (ms, not s). Under
29
+ half the window on purpose: a tick delayed by a blocking host call still
30
+ lands. No server-wide `idleTimeout` — the three Bun.serve sites untouched.
31
+ - tests/shadow-route.test.ts — three pins: default ≤ half the idle timeout;
32
+ the idle-timeout constant == the installed bun-types `@default 10` (HTTP
33
+ block); read-only, main.ts / smoke/boot.ts do not lower it. Each pin
34
+ negative-tested by mutating its constant.
35
+ - docs/pending-verification.md — one MANUAL-VERIFY under `## Plan bugs-0915 / S1`
36
+ (bench: a >12 s simulate run ends in one `event: result`, 0 timeout lines).
37
+
38
+ Post-mortem: docs/debug/moderate/2026-09-16-shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout.md
39
+ (Lessons end in `Gate check:` naming the three-test ratchet.)
40
+ ARCHITECTURE.md: no update needed — no structural change; the enumeration
41
+ issue (three Bun.serve sites, one handler) is already in CLAUDE.md's Gotchas.
42
+
43
+ Gate: `bun run check` in the worktree, Linux bun on PATH — all 12 links
44
+ pass, 5645 pass / 9 skip / 0 fail. (First run had five typecheck links red
45
+ on the environment: bare `tsc` resolved to the Windows binary over a
46
+ `//wsl.localhost` UNC path — the known interop gotcha; not code.)
47
+ Review: fork agent — no defects; two notes applied (read-only site pin, doc wording).
48
+ Overlap: preflight exit 0; merge-time check empty; rebase clean.
49
+ Delivery: squash 9bf98e7 on dev, pushed 3fb92e5..9bf98e7 (carried S2's
50
+ promotion 2575eb2). Note `core.hooksPath` is `D:\Projects\mercury\.githooks`,
51
+ a Windows path — the pre-push gate does not run from this WSL checkout; the
52
+ gate above is the evidence.
53
+ Deploy: restart-only (host file, not baked; no version bump). Restart in a
54
+ proven gap, then run the MANUAL-VERIFY line on the bench.
55
+ Worktree and branch removed.
56
+ ~~~
@@ -0,0 +1,51 @@
1
+ ## Launched — 2026-09-15T17:57Z
2
+ Branch: worktree-fix-smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member
3
+
4
+ ## Report — 2026-09-15T21:55Z
5
+ Outcome: blocked
6
+ Delivered: —
7
+ Blocked-on: push to origin — squash-merged to local dev as 70f06c7; the push is held for owner approval (CLAUDE.local.md push rule; the classifier refused push-integration.sh). Also: dev is red on S1's post-mortem in tests/release-gate-map.test.ts (see Siblings must know), so the pre-push gate would refuse.
8
+ Deploy needs: restart
9
+ Files changed:
10
+ - docs/debug/moderate/2026-09-15-smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member.md
11
+ - docs/debug/smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member.md
12
+ - docs/pending-verification.md
13
+ - docs/smoke-fixtures.md
14
+ - src/adapters/whatsapp-thread.ts
15
+ - src/core/simulate-ingress.ts
16
+ - src/smoke/cli.ts
17
+ - tests/release-gate-map.test.ts
18
+ - tests/smoke-cli.test.ts
19
+ - tests/whatsapp-thread.test.ts
20
+ Siblings must know:
21
+ - S1: `docs/debug/moderate/2026-09-16-shadow-simulate-stream-dies-at-bun-serve-10s-idle-timeout.md` fails `tests/release-gate-map.test.ts` on dev — its `Gate check:` is a `- ` bullet (the parser wants the line at column 0) and names a test, not a gate id. Fix: a column-0 `Gate check:` line naming a known id, or an allowlist entry in `tests/fixtures/release-gate/regression-map.json`. Red on plain dev, independent of S2.
22
+ - New helper `whatsAppCallerId(jid)` in `src/adapters/whatsapp-thread.ts` is the one spelling of the `space_roles` key. S3 (owner of `src/bridges/`): `src/bridges/whatsapp.ts` still inlines `whatsapp:${authorUserId}` — route it through the helper when convenient (recorded as a residual in the S2 post-mortem).
23
+ - New gate id `smoke.role` (the role read-back) registered in `tests/release-gate-map.test.ts` SMOKE_IDS.
24
+ --- final report (verbatim) ---
25
+ ~~~
26
+ **Fixed** — `mercury smoke` promoted the synthetic admin under the bare jid; every `role: admin` fixture ran as `member`.
27
+
28
+ Files changed (squash `70f06c7` on local `dev`):
29
+ - `src/adapters/whatsapp-thread.ts` — new `whatsAppCallerId(jid)` = `whatsapp:<jid>`, the one function for the `space_roles` key
30
+ - `src/core/simulate-ingress.ts` — `readCallerRole` builds its key with it
31
+ - `src/smoke/cli.ts` — admin promotion keyed through the helper; owner leg strips a leading `whatsapp:` from the config id (would otherwise double to `whatsapp:whatsapp:…`)
32
+ - `tests/smoke-cli.test.ts` — prefixed-key read-back + bare key absent; real-`Db` `resolveRole` after promotion → `admin`, exactly one row; owner both spellings. Negative-tested: fix reverted → both red
33
+ - `tests/whatsapp-thread.test.ts` — helper pinned
34
+ - `tests/release-gate-map.test.ts`, `docs/smoke-fixtures.md` — role read-back registered as gate id `smoke.role`
35
+ - `docs/pending-verification.md` — R3.2 bare-jid line superseded under `## Plan bugs-0915 / S2` (3 MANUAL-VERIFY lines, restart-gated)
36
+
37
+ Post-mortem archived: `docs/debug/moderate/2026-09-15-smoke-admin-fixture-promotes-the-bare-jid-so-the-run-resolves-it-as-member.md` — `Gate check: smoke.role`. Residuals recorded: bridge's inline spelling (S3's file), `whatsapp-db-lookups.ts:50`, owner-leg double row (runtime design).
38
+
39
+ ARCHITECTURE.md: no update needed (no structural change; one helper added to an existing pure module).
40
+
41
+ Gate: `bun run check` (Linux bun) all 12 links green before the rebases (5645/9/0). After rebasing onto `9bf98e7`: 11 green, `test` red on ONE test that is S1's — `moderate/2026-09-16-shadow-simulate-stream-dies…` has no parseable `Gate check:` line and no allowlist entry; confirmed red on plain `dev` in the primary checkout. Zero new errors from S2.
42
+
43
+ Overlap: preflight exit 0 (initial and expanded list); merge-time check empty; two rebases (upstream deps/version bumps, then S1's landing) — one keep-both conflict in `docs/pending-verification.md`, resolved. PLAN-PASSED(overlap): none needed.
44
+
45
+ Commit pushed to dev: **NO.** The promotion push was refused by the auto-mode classifier and the local rule holds the remote on owner approval; `dev` is 1 ahead of `origin/dev` (`70f06c7`). Worktree and branch removed; branch proven contained (`git diff dev <branch>` empty).
46
+
47
+ **Next:** fix S1's `Gate check:` line (or allowlist it) so `dev` is green, then `git push origin dev` — the pre-push gate runs the same suite and will refuse until that one test passes.
48
+
49
+ Also needs you:
50
+ - **Deploy** — restart-only (`~/sync-mercury.sh` in a gap), then the three `MANUAL-VERIFY(2026-09-15)` lines under `## Plan bugs-0915 / S2` (one costs a container run).
51
+ ~~~
@@ -0,0 +1,60 @@
1
+ ## Launched — 2026-09-15T22:20Z
2
+ Branch: worktree-fix-slack-and-teams-bridges-report-a-group-as-a-dm
3
+
4
+ ## Report — 2026-09-15T22:50Z
5
+ Outcome: blocked
6
+ Delivered: —
7
+ Blocked-on: push to origin — squash-merged to local dev as 835e04a (+ a57a2ae release-gate allowlist); the push is held for owner approval (CLAUDE.local.md push rule). dev is green: bun run check, all 12 links.
8
+ Deploy needs: restart
9
+ Files changed:
10
+ - docs/debug/moderate/2026-09-16-slack-and-teams-bridges-report-a-group-as-a-dm.md
11
+ - docs/debug/slack-and-teams-bridges-report-a-group-as-a-dm.md
12
+ - src/bridges/slack.ts
13
+ - src/bridges/teams.ts
14
+ - src/bridges/telegram.ts
15
+ - tests/fixtures/release-gate/regression-map.json
16
+ - tests/slack-bridge.test.ts
17
+ - tests/teams-bridge.test.ts
18
+ - tests/telegram-bridge.test.ts
19
+ Siblings must know:
20
+ - `src/bridges/teams.ts` now exports `teamsThreadIsDM(threadId)`; `parseThread` delegates to it. A Teams thread id may carry a fourth part (`:personal|groupChat|channel`, from @chat-adapter/teams ≥ 4.40 `encodeThreadId`) and it wins over the `19:` heuristic.
21
+ - `tests/fixtures/release-gate/regression-map.json` gained one `code` entry for this post-mortem (S2's surface, S2 landed) — the same shape S1's `169500f` took; a `Gate check:` inside a `- ` bullet is invisible to `tests/release-gate-map.test.ts`, so write it at column 0 or allowlist.
22
+ - The primary checkout's `node_modules` was stale (`chat` 4.32.0 vs `^4.40.0` since `753ea5e`); five typecheck links were red on dev for that reason alone. Ran `bun install --frozen-lockfile` there (2026-09-16); a worktree that borrows it is fine now.
23
+ - S2's note about `src/bridges/whatsapp.ts` inlining `whatsapp:${authorUserId}` instead of `whatsAppCallerId` was left alone — out of this bug's scope; it stays recorded in S2's post-mortem residuals.
24
+ --- final report (verbatim) ---
25
+ ~~~
26
+ S3 — slack-and-teams-bridges-report-a-group-as-a-dm — squash 835e04a on local dev, NOT pushed (held for approval)
27
+
28
+ Fixed: three of five `PlatformBridge.parseThread` implementations returned
29
+ `isDM: true` in their absent/ambiguous case, so a multi-party conversation
30
+ answered every message without a trigger and was stored as `kind: "dm"`.
31
+ - src/bridges/slack.ts — `isDM = ch.startsWith("D")`; `G…` (private channel /
32
+ mpim) is a group; matches the adapter's own `isDM`.
33
+ - src/bridges/teams.ts — `teamsThreadIsDM` + `decodeTeamsConversationId`.
34
+ `let isDM = true` and the empty catch are gone (the catch was dead:
35
+ `Buffer.from(_, "base64url")` never throws). Decode failure = base64url
36
+ round-trip + U+FFFD check → warn + group. Unknown conversation type → warn +
37
+ group. The adapter's explicit fourth-part type wins when present.
38
+ - src/bridges/telegram.ts — empty chat id is not a DM (the brief's third leg).
39
+ - Tests: G…/empty cases in tests/slack-bridge.test.ts, empty case in
40
+ tests/telegram-bridge.test.ts, new tests/teams-bridge.test.ts (9 cases,
41
+ logger.warn spied). Each guard negative-tested red with its fix reverted.
42
+ - Existing rows: NO repair — `db.ensureConversation` rewrites `kind` on every
43
+ inbound message, so a wrong row heals when it next speaks; live box is
44
+ WhatsApp-only (17 rows, none slack/teams/telegram). One-line UPDATE for a
45
+ dormant row is written in the post-mortem for the owner.
46
+
47
+ Post-mortem: docs/debug/moderate/2026-09-16-slack-and-teams-bridges-report-a-group-as-a-dm.md
48
+ (Lessons end in a named ratchet; allowlisted `code` in regression-map.json — a57a2ae.)
49
+ ARCHITECTURE.md: no update — no structural change.
50
+ Gate: bun run check on dev, all 12 links pass (5661 tests). Session code review: no
51
+ blocking issues (one note: a malformed Teams id would warn twice per message —
52
+ the adapter throws on those before they reach the bridge; left as-is).
53
+ Deploy: restart-only (no baked file, no version change).
54
+ Commit pushed to dev: NO — local rule holds the remote on owner approval;
55
+ `dev` is 2 ahead of `origin/dev` (835e04a, a57a2ae). Worktree and branch
56
+ removed; branch proven contained (`git diff dev <branch>` empty).
57
+
58
+ **Next:** approve the push — `git push origin dev` from ~/projects/mercury (the
59
+ pre-push gate re-runs the suite; dev is green).
60
+ ~~~
@@ -0,0 +1,46 @@
1
+ ## Launched — 2026-09-15T22:24Z
2
+ Branch: worktree-fix-shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token
3
+
4
+ ## Report — 2026-09-15T22:55Z
5
+ Outcome: delivered
6
+ Delivered: pushed
7
+ Blocked-on: —
8
+ Deploy needs: restart (this fix: host CLI only, nothing baked). NOTE dev also carries the v0.23.2 package.json bump (cc12f2c) — that one is an image rebuild for the box.
9
+ Files changed:
10
+ - docs/debug/moderate/2026-09-16-shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token.md
11
+ - docs/debug/shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token.md
12
+ - docs/pending-verification.md
13
+ - src/cli/mercury.ts
14
+ - src/ops/shadow-service.ts
15
+ - src/ops/shadow-snapshot.ts
16
+ - tests/shadow-service.test.ts
17
+ - tests/shadow-snapshot.test.ts
18
+ Siblings must know:
19
+ - `ShadowUpInputs` gained a required `credential: ShadowCredentialInputs` field and `ShadowUpPlan` a `credentialRefresh` field; `ShadowUpRefusalId` gained `credential-stale`. Anyone building `ShadowUpInputs` (tests use `okInputs()`) must supply it.
20
+ - New exports in `src/ops/shadow-snapshot.ts`: `AUTH_FILE_NAME`, `CredentialReading`, `readCredentialFile`, `refreshShadowCredential`. M1.3c (bench-patterns-0915 S4) should build on `readCredentialFile` for its shadow-only credential path; the post-mortem Residuals name what (a) leaves open.
21
+ - `.claude/` is excluded from git, so a fresh worktree has no `.claude/scripts/` — every `$(git rev-parse --show-toplevel)/.claude/scripts/…` fence in the workflow must be run against the primary checkout path from inside a worktree.
22
+ - The push carried S3's two unpushed squash commits (`835e04a`, `a57a2ae`) to origin/dev; S3 landed on local dev between my rebase and my squash.
23
+ --- final report (verbatim) ---
24
+ ~~~
25
+ S4 — shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token — DELIVERED: pushed (dev df3a43a)
26
+
27
+ Fixed (shape (a) + the up --dry-run gate; (c) stays M1.3c):
28
+ - src/ops/shadow-snapshot.ts — `readCredentialFile` (auth.json → oauth/none/unreadable reading, 12-hex sha256 fingerprint over every provider's refresh token, never a token or the parser's message), `refreshShadowCredential` (atomic tmp+rename byte copy, 0600, throws rather than deleting the destination on a missing source); the one-time copy's comment now says it is a starting point, not the credential.
29
+ - src/ops/shadow-service.ts — `ShadowUpInputs.credential`, refusal `credential-stale` (absence denies: live project unresolved / no oauth entry / unreadable, whenever the snapshot holds a credential; a snapshot with none is left as taken), `ShadowUpPlan.credentialRefresh` (computed under other refusals so --dry-run prints it; performed only with none), `planCredentialRefresh`, `describeCredentialPlan`.
30
+ - src/cli/mercury.ts (shadow up action only) — reads both files, prints `credential:` under --dry-run, copies the live file over the snapshot's after every refusal and before systemd-run, exits 1 if the copy fails. Outside the brief's predicted list: overlap re-run with it added → exit 0; the M1.3c session that owns the auth commands is an unlaunched Round 0 planning session.
31
+ - Tests: 21 new (tests/shadow-snapshot.test.ts ×11, tests/shadow-service.test.ts ×10, incl. a truth table asserting the refusal and the copy are one rule). Negative test: 4 red with the deny removed. Session review found literal NUL/\x01 bytes in the fingerprint separators (Python decoded the escapes) — replaced with \u escapes, verified 0 control bytes.
32
+ - Gate: bun run check 12/12 links, 5671 pass / 9 skip / 0 fail, re-run green after the rebase onto cc12f2c (v0.23.2 bump).
33
+
34
+ Post-mortem archived: docs/debug/moderate/2026-09-16-shadow-snapshot-auth-json-goes-stale-when-the-live-bot-rotates-its-refresh-token.md — carries `Gate check: credential.first-leg` at column 0 (proved against tests/release-gate-map.test.ts under the archived name, so S2's regression-map fixture is untouched) and a Residuals section: the shadow can still rotate first and kill the live token (M1.3c's reason to exist); both snapshot and refresh read `<dataDir>/global/auth.json`, the `config.authPath ??` default; the bench's `settled`-on-generic-failure reading is untouched.
35
+ docs/pending-verification.md: three MANUAL-VERIFY lines under `## Plan bugs-0915 / S4` (dry-run credential line current/stale; a snapshot older than one live rotation runs a turn; the refusal names its remedy).
36
+ docs/ARCHITECTURE.md: no update needed — no new module or boundary; the credential reading is one more input to the existing planShadowUp shape.
37
+
38
+ Delivery: squash df3a43a on dev, pushed cc12f2c..df3a43a (carried S3's 835e04a + a57a2ae, which had landed on local dev unpushed). Promotion commit d351b8f was pushed by the Step 2 script (carried S3's promotion 758cba0). Worktree and branch removed; state files cleaned.
39
+
40
+ Deploy: restart-only for this fix (host CLI, nothing baked) — but dev now also carries the 0.23.2 version bump from main, which is an image rebuild on the box (CLAUDE.local.md: a version bump with no source change still needs an image rebuild).
41
+
42
+ **Next:** deploy dev to the box — `~/sync-mercury.sh` in a proven gap (docker ps empty, `pgrep -af 'bin/pi ' | grep -v 'bash -lc'` empty), then `container/build.sh` for the 0.23.2 bump, then tick the S4 MANUAL-VERIFY lines from a real `shadow up --dry-run` / `shadow up`.
43
+
44
+ Also needs you:
45
+ - **Plan bugs-0915** — all four items are now delivered/landed; `/f-ops-plan next bugs-0915` to confirm, then `/f-ops-plan archive bugs-0915`.
46
+ ~~~
@@ -0,0 +1,60 @@
1
+ # Brief S1 — storage cleanup nulls every attachment row in the space
2
+
3
+ **Plan**: docs/plans/p0-bugs-0922/plan.md **Session**: S1 **Round**: 1 **Kind**: bug
4
+ **Item slug**: storage-cleanup-nulls-every-attachment-row-in-the-space
5
+
6
+ > The only file this session reads from the plan. Self-contained: it never points at a sibling's brief. The `**Item slug**:` line is the key the execution commands grep for to detect that a brief is active — keep it exactly in that shape.
7
+
8
+ ## Launch
9
+
10
+ /f-ops-plan work p0-bugs-0922 S1
11
+
12
+ ## Run
13
+
14
+ /f-bug-fix storage-cleanup-nulls-every-attachment-row-in-the-space
15
+
16
+ ## Scope notes
17
+
18
+ The bug's Notes prefer a path list over an age predicate: `cleanDir` already knows what it unlinked, so the fix is `clearAttachmentsForPaths(spaceId, deletedPaths)` (or equivalent). An age predicate would miss a replayed quoted image that is older than its message. **`src/core/reply-context.ts` is predicted only because the bug cites its adjacent-gap comment.** Rendering attachments in history is the separate idea `attachment-refs-rendered-in-history`, so do not build it here. Test: one old and one fresh file in the same space; after cleanup, only the old file's row loses its ref. Negative-test by restoring the space-wide `clearSpaceAttachments` call and confirming the test goes red. **Deploy needs:** restart only (none of these files is baked). The rows already nulled on the box cannot be recovered from the DB. If you think a backfill from the inbox filenames is worth doing, write it down as a residual rather than doing it here.
19
+
20
+ ## Owned surfaces (predicted)
21
+
22
+ - src/core/storage-cleanup.ts
23
+ - src/storage/db.ts
24
+ - src/core/reply-context.ts
25
+
26
+ ## Do not touch
27
+
28
+ - src/agent/container-entry.ts, container/agent-package.json (owned by S2 this plan)
29
+ - resources/pi-extensions/subagent/index.ts (owned by S3 this plan)
30
+ - src/config.ts (owned by S4 this plan)
31
+ - Not ours (avishai tsabari / Avishai-Tsabari, last 14 d): `src/agent/container-entry.ts`, `src/config.ts`, `src/storage/db.ts`, `container/agent-package.json` — see plan.md "Not ours"; lane cleared only by the Q1/Q2 answers. Re-check with `/f-ops-plan next p0-bugs-0922`.
32
+
33
+ ## Edges
34
+
35
+ - start-after: none (satisfied — `next` launched you)
36
+ - merge-after: none
37
+
38
+ ## Overlap verdicts
39
+
40
+ none
41
+
42
+ > The command's preflight overlap check treats a hit whose branch AND every shared file match a verdict line as answered (c), prints one `PLAN-PASSED(overlap): …` line, and carries it into the final report. Any other hit prompts as usual. The merge-time check is never waived.
43
+
44
+ ## Decisions answered
45
+
46
+ - Q1: ours (owner, 2026-09-22) — `src/agent/container-entry.ts`, `src/config.ts`, `src/storage/db.ts` are this plan's lane.
47
+
48
+ ## Ledger rules
49
+
50
+ - `docs/ROADMAP.md`: never edit. Your command writes `docs/pending-updates/storage-cleanup-nulls-every-attachment-row-in-the-space.md` as usual.
51
+ - `docs/pending-verification.md`: append only under `## Plan p0-bugs-0922 / S1`.
52
+ - Any goal's `decisions.md`: never edit; put decisions in your log's "Siblings must know".
53
+
54
+ ## Environment
55
+
56
+ - Read `CLAUDE.md` → Worktree Setup and Gotchas before opening the worktree. Nothing from them is copied here. `bun install` once in the fresh worktree; the check gate is `bun run check` (every link runs to completion — read every line).
57
+
58
+ ## Report
59
+
60
+ Your command's final report is appended to `docs/plans/p0-bugs-0922/sessions/S1.md` by its report step (path resolved through `.claude/scripts/resolve-docs-root.sh docs/plans/probe.md`). `work` verifies the append.