Skip to content

feat(bin): add self-hosted Discord relay - #11

Merged
Ivory2024 merged 22 commits into
mainfrom
fm/firstmate-discord-selfhosted-relay-20260920
Sep 20, 2026
Merged

Ivory2024 merged 22 commits into
mainfrom
fm/firstmate-discord-selfhosted-relay-20260920

Conversation

@Ivory2024

Copy link
Copy Markdown
Owner

Intent

Add additive self-hosted Discord connection for Firstmate with bot token, collision handling excluding channel 1551134713727426570 by default, documentation in docs/configuration.md, and regression tests in tests/fm-discord-selfhosted.test.sh

What Changed

  • Added a token-gated self-hosted Discord poll/reply path using Discord REST API, local Firstmate inbox/context/outbox records, configurable channels and DMs, and a default exclusion for channel 1551134713727426570.
  • Integrated self-hosted Discord activation into bootstrap, watcher cadence, harness auto-arm, and x-reply/x-dismiss routing with generated local artifacts.
  • Documented .env configuration and added regression coverage for no-token behavior, message ingestion and collision filtering, dry-run routing, identity validation, and bootstrap activation.

Risk Assessment

🚨 High: The change currently does not integrate its generated check with the watcher, can bypass configured dry-run safety and writes protected state without guarded publication, so the intended feature is not safe to merge.

Testing

The targeted connector regression covered ingestion, collision exclusion, DM cursor advancement, reply routing, symlink safety, and bootstrap activation/failure handling. These results were not live product validation. The mandated live Herdr surface remains untested due to the missing Herdr client.

  • Live validation: ⚠️ inconclusive - 0 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Poll an allowed Discord channel and create inbox/context records plus an x-mention wake ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Ignore channel 1551134713727426570 and advance disabled-DM cursors ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Route self-hosted Discord replies and reject untrusted context identity ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Enable Discord mode and report artifact-publication failure ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Run the connection through an isolated Herdr lab against the real product surface ⏸️ untested no The required herdr executable is unavailable on PATH, with no repository-local binary supplied. Provide the approved Herdr client and a real Discord bot token/test channel, then rerun through `bin/f…
Evidence: Focused self-hosted Discord regression
Focused regression suite passed: ingestion, collision/DM cursor handling, reply routing, identity safety, and bootstrap activation/failure handling.
Evidence: Herdr lab setup blocker
`fm-herdr-lab.sh prepare fm-lab-discord-20260920` returned `fm-herdr-lab: herdr is required`.
- Outcome: ⚠️ 1 warning across 1 run (4m47s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 14 issues (5 errors, 9 warnings)
  • 🚨 bin/fm-bootstrap.sh:1166 - Token-only activation does not reach the watcher. bin/fm-bootstrap.sh:1166 creates discord-watch.check.sh, but bin/fm-watch.sh:2375-2384 special-cases only x-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated .check-trust registration. discord-mode.env is also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.
  • 🚨 bin/fm-x-reply.sh:323 - Dry-run can post publicly when configured through .env. fm-x-reply.sh loads FMX_DRY_RUN into FMX_DRY and then routes at bin/fm-x-reply.sh:323-326; the Discord adapter only checks the raw FMX_DRY_RUN environment variable at bin/fm-discord-reply.js:25. Because the wrapper does not export the .env value, FMX_DRY_RUN=1 in .env is ignored and a live Discord POST occurs.
  • 🚨 bin/fm-discord-poll.js:147 - The new Node poller writes inbox, context, and offer files with raw writeFileSync at bin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files at bin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by the mode option. This violates the protected state-file invariant and can cause data disclosure or overwrite.
  • ⚠️ bin/fm-discord-poll.js:80 - FM_DISCORD_ALLOW_DMS is an extra user-visible option not required by the stated intent, and its documented false value is ignored: bin/fm-discord-lib.sh:62-67 exports it, but bin/fm-discord-poll.js:80-85 accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator sets FM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.
  • ⚠️ tests/fm-discord-selfhosted.test.sh:33 - The added regression tests do not exercise the new ingestion or collision behavior. test_ingestion_payload_shape_and_wake writes the inbox/context files and prints the wake line itself (tests/fm-discord-selfhosted.test.sh:33-68) instead of running fm-discord-poll.js; test_collision_exclusion_filter reimplements the comma split in a separate node -e snippet (tests/fm-discord-selfhosted.test.sh:114-120). These assertions can pass while the actual poller or watcher is broken.
  • ⚠️ bin/fm-discord-poll.js:89 - Every accepted Discord message creates state/x-context/<request_id>.offered.json at bin/fm-discord-poll.js:89-90 and :149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.

🔧 Fix applied.
8 issues (4 errors, 4 warnings) still open:

  • 🚨 bin/fm-bootstrap.sh:1166 - Token-only activation does not reach the watcher. bin/fm-bootstrap.sh:1166 creates discord-watch.check.sh, but bin/fm-watch.sh:2375-2384 special-cases only x-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated .check-trust registration. discord-mode.env is also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.
  • 🚨 bin/fm-discord-poll.js:147 - The new Node poller writes inbox, context, and offer files with raw writeFileSync at bin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files at bin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by the mode option. This violates the protected state-file invariant and can cause data disclosure or overwrite.
  • ⚠️ bin/fm-discord-poll.js:80 - FM_DISCORD_ALLOW_DMS is an extra user-visible option not required by the stated intent, and its documented false value is ignored: bin/fm-discord-lib.sh:62-67 exports it, but bin/fm-discord-poll.js:80-85 accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator sets FM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.
  • ⚠️ bin/fm-discord-poll.js:89 - Every accepted Discord message creates state/x-context/<request_id>.offered.json at bin/fm-discord-poll.js:89-90 and :149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.
  • 🚨 bin/fm-discord-poll.js:161 - A poll can surface multiple new messages, but bin/fm-discord-poll.js:161 prints one x-mention line per message. The watcher consumes the entire multiline result as one check payload at bin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture at tests/fm-discord-selfhosted.test.sh:36 exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.
  • 🚨 bin/fm-discord-reply.js:55 - The new reply path reads state/x-context/<id>.json through raw existsSync/readFileSync at bin/fm-discord-reply.js:55-60; the poll path similarly treats any existsSync result at bin/fm-discord-poll.js:106 as a valid offer marker. A pre-existing symlink or hardlink can therefore make a reply adopt channel/message IDs from an unrelated file, or permanently suppress ingestion for a message. Use the existing private-artifact identity validation and atomic offer-claim helpers at the shared boundary.
  • ⚠️ bin/fm-bootstrap.sh:1173 - The fix round added watcher dispatch for discord-watch.check.sh, but the cadence artifact is still unused. bin/fm-bootstrap.sh:1173 writes config/discord-mode.env and docs/configuration.md:673 promises 30-second polling, while the active arm paths source only x-mode.env (.pi/extensions/fm-primary-pi-watch.ts:985, .omp/extensions/fm-primary-omp-watch.ts:895, and bin/fm-claude-stop-autoarm.sh:250). bin/fm-watch.sh:233 therefore keeps the default 300-second interval unless the caller manually exports FM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.
  • ⚠️ bin/fm-discord-poll.js:86 - The poller fetches only the newest 10 messages per channel at bin/fm-discord-poll.js:86 and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.

🔧 Fix applied.
9 issues (3 errors, 6 warnings) still open:

  • 🚨 bin/fm-bootstrap.sh:1166 - Token-only activation does not reach the watcher. bin/fm-bootstrap.sh:1166 creates discord-watch.check.sh, but bin/fm-watch.sh:2375-2384 special-cases only x-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated .check-trust registration. discord-mode.env is also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.
  • 🚨 bin/fm-discord-poll.js:147 - The new Node poller writes inbox, context, and offer files with raw writeFileSync at bin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files at bin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by the mode option. This violates the protected state-file invariant and can cause data disclosure or overwrite.
  • ⚠️ bin/fm-discord-poll.js:80 - FM_DISCORD_ALLOW_DMS is an extra user-visible option not required by the stated intent, and its documented false value is ignored: bin/fm-discord-lib.sh:62-67 exports it, but bin/fm-discord-poll.js:80-85 accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator sets FM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.
  • ⚠️ bin/fm-discord-poll.js:89 - Every accepted Discord message creates state/x-context/<request_id>.offered.json at bin/fm-discord-poll.js:89-90 and :149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.
  • 🚨 bin/fm-discord-poll.js:161 - A poll can surface multiple new messages, but bin/fm-discord-poll.js:161 prints one x-mention line per message. The watcher consumes the entire multiline result as one check payload at bin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture at tests/fm-discord-selfhosted.test.sh:36 exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.
  • ⚠️ bin/fm-bootstrap.sh:1173 - The fix round added watcher dispatch for discord-watch.check.sh, but the cadence artifact is still unused. bin/fm-bootstrap.sh:1173 writes config/discord-mode.env and docs/configuration.md:673 promises 30-second polling, while the active arm paths source only x-mode.env (.pi/extensions/fm-primary-pi-watch.ts:985, .omp/extensions/fm-primary-omp-watch.ts:895, and bin/fm-claude-stop-autoarm.sh:250). bin/fm-watch.sh:233 therefore keeps the default 300-second interval unless the caller manually exports FM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.
  • ⚠️ bin/fm-discord-poll.js:86 - The poller fetches only the newest 10 messages per channel at bin/fm-discord-poll.js:86 and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.
  • ⚠️ bin/fm-discord-poll.js:145 - The cursor fix leaves disabled DMs unadvanced: bin/fm-discord-poll.js:145 continues before writeCursor, making the duplicate guard at :153-155 unreachable. A DM arriving after the last permitted message is fetched on every poll because the channel cursor never advances. Consolidate the guard and advance the cursor once; sibling site: bin/fm-discord-poll.js:153-155.
  • ⚠️ bin/fm-bootstrap.sh:1166 - Bootstrap ignores both private-artifact publication failures at bin/fm-bootstrap.sh:1166 and :1173, then always prints that Discord mode is armed at :1175. If either artifact path is invalid, unsafe, or unwritable, the watcher receives no valid shim/cadence but the operator is told activation succeeded. Propagate the failure or emit an explicit inactive result.

🔧 Fix applied.
14 issues (5 errors, 9 warnings) still open:

  • 🚨 bin/fm-bootstrap.sh:1166 - Token-only activation does not reach the watcher. bin/fm-bootstrap.sh:1166 creates discord-watch.check.sh, but bin/fm-watch.sh:2375-2384 special-cases only x-watch.check.sh; the Discord file falls into the generic custom-check path, which requires an uncreated .check-trust registration. discord-mode.env is also not consumed by the existing cadence setup. Thus the required self-hosted connection never polls or emits wakes.
  • 🚨 bin/fm-discord-poll.js:147 - The new Node poller writes inbox, context, and offer files with raw writeFileSync at bin/fm-discord-poll.js:147-149, and the reply adapter writes outbox files at bin/fm-discord-reply.js:76, without the repository's guarded private-artifact publication. A pre-existing symlink or hardlink under these state directories can redirect writes to another file, and an existing file's mode is not tightened by the mode option. This violates the protected state-file invariant and can cause data disclosure or overwrite.
  • ⚠️ bin/fm-discord-poll.js:80 - FM_DISCORD_ALLOW_DMS is an extra user-visible option not required by the stated intent, and its documented false value is ignored: bin/fm-discord-lib.sh:62-67 exports it, but bin/fm-discord-poll.js:80-85 accepts every DM unconditionally. For an explicitly listed DM channel, a message with no guild ID is ingested even when the operator sets FM_DISCORD_ALLOW_DMS=false. Remove this option as unnecessary, or authorize retaining and implementing its behavior.
  • ⚠️ bin/fm-discord-poll.js:89 - Every accepted Discord message creates state/x-context/<request_id>.offered.json at bin/fm-discord-poll.js:89-90 and :149, but this new poller never invokes the existing context/offer retention pruning used by the hosted poll path. A long-running self-hosted home therefore accumulates one marker per handled message indefinitely.
  • 🚨 bin/fm-discord-poll.js:161 - A poll can surface multiple new messages, but bin/fm-discord-poll.js:161 prints one x-mention line per message. The watcher consumes the entire multiline result as one check payload at bin/fm-watch.sh:2388/:2446, sanitizes embedded newlines, and queues one wake. With two accepted messages in one poll, both inboxes and offer markers are created but only one request is reliably surfaced; the next poll skips both markers. The regression fixture at tests/fm-discord-selfhosted.test.sh:36 exercises only one accepted message. Preserve the one-request-per-wake contract by processing or surfacing each request separately.
  • ⚠️ bin/fm-bootstrap.sh:1173 - The fix round added watcher dispatch for discord-watch.check.sh, but the cadence artifact is still unused. bin/fm-bootstrap.sh:1173 writes config/discord-mode.env and docs/configuration.md:673 promises 30-second polling, while the active arm paths source only x-mode.env (.pi/extensions/fm-primary-pi-watch.ts:985, .omp/extensions/fm-primary-omp-watch.ts:895, and bin/fm-claude-stop-autoarm.sh:250). bin/fm-watch.sh:233 therefore keeps the default 300-second interval unless the caller manually exports FM_CHECK_INTERVAL. Source the Discord cadence through the same shared arm path or remove the false cadence claim.
  • ⚠️ bin/fm-discord-poll.js:86 - The poller fetches only the newest 10 messages per channel at bin/fm-discord-poll.js:86 and keeps no Discord message cursor. If 11 messages arrive between polls and the mention is the 11th-oldest, it leaves the API window permanently without creating an inbox or marker. The documented 30-second cadence is also currently inactive, making this easier to reach. A durable cursor/backfill strategy is needed for lossless ingestion and would extend the stated additive slice, so that remedy requires authorization.
  • ⚠️ bin/fm-discord-poll.js:145 - The cursor fix leaves disabled DMs unadvanced: bin/fm-discord-poll.js:145 continues before writeCursor, making the duplicate guard at :153-155 unreachable. A DM arriving after the last permitted message is fetched on every poll because the channel cursor never advances. Consolidate the guard and advance the cursor once; sibling site: bin/fm-discord-poll.js:153-155.
  • ⚠️ bin/fm-bootstrap.sh:1166 - Bootstrap ignores both private-artifact publication failures at bin/fm-bootstrap.sh:1166 and :1173, then always prints that Discord mode is armed at :1175. If either artifact path is invalid, unsafe, or unwritable, the watcher receives no valid shim/cadence but the operator is told activation succeeded. Propagate the failure or emit an explicit inactive result.
  • 🚨 bin/fm-discord-poll.js:214 - The cursor fix round can permanently suppress a first wake. After claimOffer() creates the durable offer marker, writeCursor() at bin/fm-discord-poll.js:214 can fail on an unsafe, unwritable, or otherwise invalid cursor path; the outer catch at :219-221 then exits without printing x-mention. The next poll sees the offer marker, skips emission, and leaves the inbox stranded. Preserve at-least-once wake delivery when cursor publication fails. The other cursor sites at :139, :149, :153, :164, and :210 are non-first-delivery paths.
  • 🚨 bin/fm-discord-poll.js:127 - The cursor fix round still drops messages beyond the Discord page window. bin/fm-discord-poll.js:127 requests only 10 messages after the cursor; if 11 messages arrive and the mention is among the oldest omitted records, the code advances the cursor through the returned newer records and permanently skips that mention. The regression server at tests/fm-discord-selfhosted.test.sh:48-50 filters by after but does not enforce the API limit, so it cannot expose this sequence. Paginate or otherwise drain the bounded backlog before advancing past it.
  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:985 - The cadence fix round sources discord-mode.env only in the Pi, OMP, and Claude arm paths (.pi/extensions/fm-primary-pi-watch.ts:985, .omp/extensions/fm-primary-omp-watch.ts:895, bin/fm-claude-stop-autoarm.sh:251). Active sibling arm paths still source only x-mode.env: .opencode/plugins/fm-primary-watch-arm.js:345 and bin/fm-turnend-guard-cursor.sh:287. A self-hosted Discord home using those paths therefore keeps the default 300-second check interval despite bin/fm-bootstrap.sh:1173 and docs/configuration.md:673 promising 30-second polling.
  • ⚠️ bin/fm-bootstrap.sh:1176 - The latest bootstrap failure fix reports self-hosted Discord as inactive but leaves partial or stale artifacts. If Node is missing at bin/fm-bootstrap.sh:1153, if shim publication fails at :1166, or if cadence publication fails at :1176 after the shim was published, existing/generated artifacts are not removed. The watcher can consequently continue dispatching a stale shim at the wrong cadence while bootstrap says the mode is inactive. Remove the complete artifact pair on every failed activation path.
  • ⚠️ bin/fm-discord-reply.js:151 - Self-hosted multi-message replies are posted sequentially at bin/fm-discord-reply.js:115-161, while bin/fm-x-reply.sh:301-327 intentionally produces multiple Discord-sized chunks. If the first POST succeeds and a later POST fails or is rate-limited, the command exits nonzero after partial external delivery; a retry sends the first chunk again because no per-chunk progress or idempotency record exists. The defect is source-verifiable, but the smallest honest remedy requires durable send progress/idempotency beyond this additive slice, so retaining this behavior needs authorization or an explicitly accepted duplicate-delivery policy.
⚠️ **Test** - 1 warning
  • ⚠️ live validation verdict: inconclusive (0 of 5 scenarios were driven live against the product); untested: Poll an allowed Discord channel and create inbox/context records plus an x-mention wake, Ignore channel 1551134713727426570 and advance disabled-DM cursors, Route self-hosted Discord replies and reject untrusted context identity, Enable Discord mode and report artifact-publication failure, Run the connection through an isolated Herdr lab against the real product surface
  • Live validation: ⚠️ inconclusive - 0 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Poll an allowed Discord channel and create inbox/context records plus an x-mention wake ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Ignore channel 1551134713727426570 and advance disabled-DM cursors ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Route self-hosted Discord replies and reject untrusted context identity ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Enable Discord mode and report artifact-publication failure ⏸️ untested no The prior payload only recorded a focused regression artifact and explicitly marked this scenario live=false; it did not establish a live product result.
Run the connection through an isolated Herdr lab against the real product surface ⏸️ untested no The required herdr executable is unavailable on PATH, with no repository-local binary supplied. Provide the approved Herdr client and a real Discord bot token/test channel, then rerun through `bin/f…
  • tests/fm-discord-selfhosted.test.sh
  • bin/fm-herdr-lab.sh prepare fm-lab-discord-20260920
  • node --version
  • jq --version
  • git status --short --branch
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

kunchenguid and others added 14 commits September 20, 2026 18:59
…henguid#4894)

* fix(bin): let a background Claude session keep owning its session lock

Session-lock ownership was decided by process ancestry alone. Under an
unattended Claude session the model loop runs in a transient bg-spare
bridged to the front-end by a shared daemon; when that bridge is
recycled the contiguous claude-named ancestry from a hook to the
recorded owner breaks while the owner pid stays alive, so the Stop
auto-arm stood down as a foreign live owner, the turn-end guard ended
every turn with its read-only diagnostic, and fm-lock.sh refused - a
self-sustaining outage until restart.

Ownership is now ancestry membership OR a trusted same-session id,
never id-first:

- fm-session-lock-lib.sh accepts CLAUDE_CODE_SESSION_ID only when
  CLAUDE_PID is a Claude-shaped member of the current contiguous run,
  compares it against the id recorded in state/.lock-session, and
  requires the recorded pid to still be a live harness. No id, no
  sidecar, an untrusted id, a different id, or a dead recorded pid
  leaves the ancestry verdict unchanged. Ids are never read from ps
  argv.
- fm-lock.sh accepts a same-session holder at both refusal sites,
  writes, refreshes, and clears the sidecar only under its claim lock
  (including the early already-mine exit, skipped only while the
  deferred startup sweep leases that lock), keeps it byte-identical
  across a same-session confirmation, records CLAUDE_PID on lock line 1
  for a session with a trusted id so a shared daemon or front-end that
  outlives the session never keeps a dead session's lock alive, never
  rewrites a live line 1 on a same-session confirmation, and names the
  recorded id in the live-owner refusal.
- The .lock line-1 format is unchanged, so every reader that takes the
  whole first line as the pid keeps working; the guard's foreign-owner
  exit is unchanged and inherits the fix through the shared predicate.

Tests: the ancestry suite drives the ancestry and id signals apart in a
deterministic process table (asserting the divergence) and runs a real
orphaned front-end/daemon/pty-host/spare tree through six phases with
the real lock, auto-arm, and guard scripts; the foreign-owner repro
keeps its negative control and adds a same-id positive control.

Disclosure: no live unattended Claude background session ran on the
verifying machine. The topology is documented by the real process
listings in kunchenguid#3902, kunchenguid#2314, kunchenguid#3398, and kunchenguid#4066; coverage is the structural
predicate plus the executable fixtures, not a live pass.

Residual: bin/fm-sessionstart-nudge.sh keeps its own private ancestry
walk (it only decides whether to print a nudge) and may nudge on a
resume in the recycled case.

Out of scope, deliberately: no structured lock format, no guard budget
changes, no daemon-identity rejection, no fork lineage.

* no-mistakes(review): Wait for claim lock; revert failed sidecars

* no-mistakes(review): Revalidate ownership after wait; restore sidecars

* no-mistakes(review): Roll back sidecar by publication phase

* no-mistakes(review): Restore sidecar only if lock line is unchanged

* no-mistakes(review): Trust session ids without a spelling allowlist

* no-mistakes(review): Disarm sidecar rollback before backup cleanup

* no-mistakes(document): Updated session-lock ownership documentation
* feat: park main under the away posture on Pi

While the away-posture record exists on a Pi primary, the supervision branch
takes every actionable wake, no processing turn opens on main, captain rows
accumulate for the return brief, and main's standing authority relocates to
the branch through the existing guarded scripts.

- lib/fm-branch-dispatch.ts: read the record at every routing decision; while
  it exists claim check, decision-owned, and heartbeat rows too, keeping the
  two broken-queue vetoes; expose checkSeqs so a claimed check row lifts task
  scoping.
- fm-primary-pi-watch.ts: offer every actionable row under the record; a
  declined wake and every watcher-failure alarm still reach main.
- fm-branch-supervision.ts: drop the legacy .afk decline; append a fixed
  POSTURE: AWAY tail carrying the record's read-back verbatim per wake; open no
  processing request while the record exists, re-checked immediately before a
  request would open and at every run boundary; present the accumulated rows
  at the first run boundary after archive.
- fm-lease-lib.sh: fm_lease_forbid_branch passes the branch for opted-in
  actions only while fm-afk-contract.sh validate succeeds on a confirmed live
  record; PR merge, fresh spawn, and decision answer opt in, local landing
  never does.
- fm-send.sh: a --resolve-key naming an open needs-decision or captain-held
  task is a decision answer and meets the partition; blocked: keys stay
  steering.
- fm-spawn.sh: enforce the record's spend cap for a fresh ordinary spawn by
  either actor; relaunches and secondmates exempt.
- fm-branch-prompt.sh: fixed Postures section and the verbatim
  ask-user-authority policy; the prefix stays byte-stable.
- fm-afk-return.sh: count what the away session handled from the store.
- docs, afk skill, AGENTS.md stub: main parked on Pi, green merge gate
  absolute while away.
- tests: watcher and branch extension suites, fleet-record, merge, and
  decision-answer suites cover the relocation, the vetoes, the tail, the
  parked processing turn, the cancellation, the re-presentation, and the
  spend cap; dated live-guard evidence recorded.

* no-mistakes(review): Refuse branch merge after preflight archive race

* no-mistakes(review): Fix away wake, spawn, and processing races

* no-mistakes(review): Suppress parked processing; narrow away-only rejection

* no-mistakes(review): Abort dedicated processing; gate branch spawn once

* no-mistakes(review): Stamp away-only on the dispatch offer

* no-mistakes(review): Treat invalid away records as spend-cap absence

* no-mistakes(review): Drop spawn test hook; abort processing-opened runs

* no-mistakes(review): Bind abort to opening prompt; cap-read absence

* no-mistakes(review): Limit away branch spawn to queued work only

* no-mistakes(document): Correct AFK posture documentation
* ci: simplify CI job timeouts to a three-tier policy

Replace the scattered per-job timeout values (10m parallel, 25m lint, 30m
serial, 10m macOS) with three readable tiers, each a hang tripwire with
headroom rather than a packing estimate:

- fast (5m): coverage guard, repo invariants, timing aggregate
- normal (30m, one shared budget): lint partitions, portable parallel
  shards, portable serial shards, macOS stock Bash
- heavy (Herdr only): 20m step tripwire on the family run so always()
  cleanup still runs, under a 75m job-level last-resort backstop

The workflow's header comment states the policy and points at
docs/fm-test-portable-shards.md "Timeouts", which now owns it, and each
job names its tier beside timeout-minutes. tests/fm-ci-workflow.test.sh
asserts the policy against the parsed workflow instead of the old
per-job minute values: every job joins exactly one tier, exactly three
distinct job-level values exist, the fast tier stays within 5-10
minutes, the normal budget stays at least double the modeled parallel
lane sum reported by fm-test-run.sh --check-coverage, and the Herdr step
tripwire stays below its job backstop with an always() cleanup after it.

Concurrency supersession, shard counts, lane membership, and fail-fast
settings are unchanged.

* no-mistakes(review): Decouple the normal timeout from packing estimates

* no-mistakes(review): Assert Herdr teardown follows the family run

* no-mistakes(review): Pin Herdr family-run timeout to 20 minutes

* no-mistakes(review): Ignore comments when identifying Herdr steps

* no-mistakes(review): Identify Herdr steps by declarative ids

* no-mistakes(document): Clarify authoritative three-tier timeout policy
…nchenguid#4895)

* fix(bin): keep supervisor status closes from waking the same home

A drain that already folded OPEN DECISIONS has presented those bytes even
when the watcher has no matching seen marker. Treat that fold, and the
presentation cursor, as known so the bookkeeping close stays quiet while
later worker lines still signal.

* no-mistakes(review): Keep folded worker failures waking past supervisor closes

* no-mistakes(review): Wake on unlisted folded worker lines; batch multi-key closes

* no-mistakes(review): Stop folded worker resolved lines from counting as already read

* no-mistakes(document): Correct self-announced close marker contract in docs
* Stop steering operators away from Herdr

* no-mistakes(review): Neutralize remaining Herdr opt-out documentation wording
…enguid#4973)

* fix(bin): treat a live no-mistakes run as current after rebase

A running run on the task's branch is authoritative regardless of head.
Matching only the local head made a rebased in-flight run look failed.

* no-mistakes(review): restrict coarse live-any-head to foreign-branch answers

* no-mistakes(review): reject gate-parked runs from the executing predicate

* no-mistakes(review): hoist gate-marker patterns into single run-lib owner

* no-mistakes(review): require live daemon for head-free run binding

* no-mistakes(review): require answered daemon-down before unbinding live runs

* no-mistakes(review): extend daemon guard to anchored continuation routes

* no-mistakes(review): delete live-any-head; restore dead-daemon verdict

* no-mistakes(review): keep parked gates parked; name dead daemon everywhere

* no-mistakes(review): set dead-daemon verdict instead of emitting early

* no-mistakes(review): align selected route with legacy dead-daemon handling

* no-mistakes(review): drop unproven-record binds; narrow coarse gate reading

* no-mistakes(review): narrow header, drop vestigial guard, retarget tests

* no-mistakes(review): revert coarse gate override; require answered-down probe

* no-mistakes(review): cache one daemon probe; stop duplicating run id

* no-mistakes(review): restrict coarse dead-daemon verdict to moved-off rows

* no-mistakes(review): delete coarse dead-daemon extension and gate note

* no-mistakes(review): delete remaining coarse dead-daemon block and stale docs

* no-mistakes(document): document rebase-safe live-run bind and unverified-record verdict
…4994)

* fix(bin): stage the launch command in a private file and type a short source line

A long launch line typed while the fresh pane shell is still busy waits in the
terminal's canonical line buffer, which drops input past about 1,024 bytes on
macOS, so the pane was left at an unfinished command with no agent running.
fm-spawn now writes the assembled command to the task's own temp root under
umask 077 and types only a short line that sources it.

Refs kunchenguid#4559

* fix(bin): keep the per-task temp root private before staging the launch command

The root lives at a predictable path under /tmp and now holds the whole launch
command. Create it with mode 0700, refuse one that already exists as anything but
a directory owned by this user that nobody else can write, and tighten an owned
one, so no other local user can plant or swap the staged file.

Refs kunchenguid#4559

* fix(bin): enforce private staged launch file mode

* test(spawn): cover long staged Claude launches

* no-mistakes(review): Namespace launch files and prove truncation staging

* no-mistakes(review): Use immutable per-spawn launch filenames

* no-mistakes(document): Document staged launch delivery safeguards

* no-mistakes(ci): Updated eight behavior tests/fakes to execute or inspect immutable staged launch files instead of expecting inline launch commands. This restores Muse, secondmate lifecycle/restart, remote trace/parent binding, compact-adviser, and Orca coverage. All affected tests, dispatch-profile regression, fixture tests, syntax checks, ShellCheck, and git diff checks pass

---------

Co-authored-by: Vytautas Stankus <svycka@gmail.com>
* Add isolated Herdr runbook to test instructions

* no-mistakes(review): Drop substring matching from test.instructions contract

* no-mistakes(review): Assert commands.test key absence in YAML

* Drop unit-first sentence and instructions contract test

Captain-scoped follow-up on the Herdr-lab test.instructions ship:
keep the lab safety runbook only, and leave the no-mistakes contract
test focused on commands.test absence.
…uid#4873) (kunchenguid#5001)

* docs(vision): accept vendor-semantics and 9k contract-ceiling amendments (kunchenguid#4873)

Replace the pixels-of-today's-UI rule with a quarantined, version-pinned
surface-adapter exception recorded as standing debt. Cap the always-loaded
contract at 9,000 words and require prune-or-trigger before a crossing change
lands.

Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>

* docs(vision): restore accepted three-sentence vendor-semantics form (kunchenguid#4873)

Replace the compressed paraphrase with the issue's accepted wording:
a named quarantined version-pinned adapter, expected to break, recorded
as standing debt that never hardens into a shared contract.

Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
…or-owed gate (kunchenguid#4974)

* fix(watch): recheck a gate awaiting a human instead of wedge-escalating it

A lane whose validation run is parked at a gate waiting on a human
decision is correctly quiet, but nothing in its status line says so: the
evidence is the pipeline's own gate state rather than anything the worker
wrote. The wedge timer read that silence as a suspected wedge and climbed
the escalation ladder for as long as the wait lasted, and each escalation
cost a supervising turn. The landed declared-wait consult does not reach
it, because a live ordinary crewmate never reports a declared pause, and
raising FM_STALE_ESCALATE_SECS would delay genuine wedge detection for
every lane by the same amount.

The threshold now reads a second, independent record when the status line
accounts for nothing: whether the crew's current state is a gate whose
answer is owed by a human. That is minted only from the gate's own
findings table, by a row whose `action` column is exactly `ask-user`,
located by position out of the table header the way nm_gate_step_row
already reads its row - never searched for over the run payload, where a
finding's free-text description or a branch name satisfies a search just
as well. A gate awaiting the CREWMATE's own answer keeps the unchanged
escalation schedule, reason and demand-deep-inspection wording, because a
crewmate that goes quiet before answering its own gate is exactly the
wedge the ladder exists to catch.

Each kind of wait now carries the human it is on, the action that clears
it, and whether that human is the captain as data alongside the verdict,
rather than as wording chosen per branch where the recheck is written, so
the deferral cannot word one kind of wait as another and a new kind
cannot ship without deciding all of them. A parked gate has no written
record of when its wait began, so its recheck publishes no wait age at
all rather than one read from the quiet window this deferral resets on
every pass, which would report the same small number for a gate of any
age. Like every other captain-facing recheck here it is absorbed in
silence while the away-posture record exists, arming no throttle, so the
recheck is owed in full the moment the record is archived.

The consult runs only in the at-threshold branch that was about to
escalate, beside the worktree walk already there, and only for lanes
whose status line explained nothing.

Closes kunchenguid#3055

* no-mistakes(review): require an unanswered decision before deferring a parked gate

* no-mistakes(review): reset the away-silenced timer, fail-safe findings parse, US-joined wait records

* test(watch): pass the pane hash wedge_timer_check now takes

Upstream gave wedge_timer_check a sixth <pane-hash> argument for its
dead-record probe. The malformed-wait-record rounds drive the real function
directly, so they pass one, and stub fm_backend_agent_state to a live agent so
the probe that runs after a refused deferral keeps the unchanged ladder rather
than reading a backend the child shell has none of.

* no-mistakes(review): Bind parked-gate wait to its run, owe it firstmate

* no-mistakes(document): correct wait-kind count, crew-state reader scope, gate-key coupling

* feat(watch): make the parked-gate wait deferral opt-in

The wedge timer deferring a lane parked at a validation gate is new
supervision behaviour rather than a restored one, and it decides which
lanes give up the escalation ladder, so it now ships as a default-off
per-home option instead of changing every home on upgrade.

config/wedge-defer-parked-gate arms it. The flag is read before the
decision fold, so an unconfigured home spends no fold or current-state
read, writes no record, and keeps the unchanged escalation schedule,
reasons and demand-deep-inspection wording; a test counts the reader
calls in both directions to pin that.

It is not inherited by secondmate homes: each home supervises its own
crew and owns that trade separately, the same reason
config/turnend-churn-absorb is home-local.

The away-posture absorb returns to leaving the idle timer alone, which
it had restarted only because the costly consult could reach it. A
parked-gate wait is owed to the supervisor rather than the captain, so
it never enters that branch, and the recheck owed on return is again
owed in full the moment the record is archived.

* test(watch): pin that the away-silenced hold leaves the idle timer alone

The absorb no longer restarts the timer, so the recheck owed on return is
owed in full rather than a cadence into the return. Nothing asserted
that, so a restart could be reintroduced silently.

* no-mistakes(review): document away-silence rationale, pin captured gate component

* no-mistakes(test): anchor gate row scan to the braced findings header

* no-mistakes(document): pin same-block gate row invariant in crew-state comment
…uid#5007)

* fix(control): let the owning seat reclaim a task whose endpoint is gone

A destroyed pane or workspace made `missing` a terminal state. Relaunch
accepted only `dead` and said to stop the agent first; exit refused
`missing` and said to reconcile the task first; there is no reconcile
verb. Each command named the other as its prerequisite, so a task whose
terminal went away could not be reclaimed by anything, and a no-mistakes
approval it was parked on had no seat left to answer it.

`missing` is agent-free a fortiori: there is no endpoint, so there is no
agent in it. Widen the existing guards rather than add a verb.

- fm-spawn --relaunch accepts a positively proven `missing` and creates
  one fresh endpoint in the recorded worktree; the record it already
  republishes rebinds the task to it. A `dead` endpoint is still adopted
  in place.
- fm-control exit reports `endpoint-gone` instead of dying, so the
  relaunch transaction's stop step no longer dead-ends, and re-resolves
  the endpoint from the record before verifying the replacement.

The duplicate-agent refusal is untouched: both verdicts come from the
same recovery-grade classifier, which claims `missing` only from positive
absence, so `alive`, `ambiguous`, and `unreadable` all still refuse. The
backends' own create paths refuse a live same-labeled endpoint as a
second independent guard. The worktree, its branch, commits, uncommitted
changes, armed poll and registration, record rows, and status log are all
untouched - a reclaim is a recovery, never a teardown.

A secondmate is excluded: its gone-endpoint recovery already has one
owner in the session-start liveness sweep, so relaunch refuses and names
it rather than becoming a second path to the same outcome.

Tests reproduce both halves of the deadlock, the reclaim succeeding,
unlanded work surviving it, and the refusals that still hold.

* no-mistakes(review): prove endpoint absence per backend before reclaim rebinds

* no-mistakes(review): give exit and relaunch one absence proof; pin herdr rebind session

* no-mistakes(review): narrow endpoint reclaim to herdr; tmux refuses honestly

* no-mistakes(review): stop refusals and docs asserting unestablished causes

* no-mistakes(review): stop herdr fixture helper losing tmp-root registration

* no-mistakes(review): document workspace drift and absence-probe server residue

* no-mistakes(review): correct rebind limitation to its one reachable case

* no-mistakes(review): stop claiming reclaim leaves instructions untouched

* no-mistakes(document): scope fm-control-lib purity claim, note reclaim coverage

* no-mistakes(rebase): read the staged launch file in the herdr fixture

Rebasing onto main picked up kunchenguid#4994, which stages a long worker launch
command into a script and delivers the short `. '<path>'` line instead of
the literal command. The tmux fake and tests/fixtures.sh were updated for
that; the herdr fake this branch adds was written before it and still
keyed "an agent now exists on this pane" off the literal
`encode launch-brief` text, so after the rebase it never marked the
rebound pane live and the reclaim's alive-wait read `dead`.

Dereference the staged file first, exactly as the tmux fake above does.
Test-fixture only; no production path changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* no-mistakes(document): note reclaim placement in herdr and scripts inventories

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3764)

* test(status): reproduce missing event emission time

* wip(status): preserve optional event emission time

* test(status): document indirect clock stub invocation

* no-mistakes(review): Preserve historical status bytes during reply recovery

* no-mistakes(test): Fix timestamped status assertions and remote fixture dependencies

* no-mistakes(review): Preserve captain regex overrides for timestamped status events

* no-mistakes(document): Clarify status event timing and publication contracts

* no-mistakes(lint): Quote literal done to satisfy ShellCheck

* no-mistakes(ci): Captain, updated .github/workflows/ci.yml to expect 19 snapshot tests instead of 18, matching the PR’s added regression. Reproduced the failure before the fix. Stock Bash 3.2.57 verification passed: parse sweep, 19 snapshot tests, 53 Bearings tests, and the public-followup regression. Workflow lint and diff checks passed

* no-mistakes(test): Preserve terminal notifications with malformed timestamp tags

* no-mistakes(test): Stamp Rovo spawn failures with emission time

* no-mistakes(document): Verify status event documentation

* no-mistakes(lint): Fix ShellCheck quoting in status emission-time tests

* no-mistakes(ci): Captain, fixed four lifecycle assertions to accept emission timestamps while preserving publication and retry checks. Reproduced the CI failure before the fix. The lifecycle suite now passes with six Beads capability skips; syntax, targeted ShellCheck, and diff checks passed

* no-mistakes(ci): Captain, fixed malformed timestamp colons hiding actionable events using shared normalization. Original bytes and unknown ages are preserved. Regression reproduced before the fix; classifier and remote-reply suites, targeted lint, syntax, and diff checks passed

* no-mistakes(review): Stamp remote escalations at call sites, drop new flag

* no-mistakes(review): Accept stamped escalation and close lines in test assertions

* no-mistakes(review): Restore reserved-key answered-note guard for stamped closes

* test(status): accept optional emission time in PR-provenance assertions

The kunchenguid#4148 provenance test landed on main with exact unstamped greps.
Parent-channel lines from this branch carry [at=<epoch>], so strip only
that tag before the same exact match. No production change.

* no-mistakes(review): Accept stamped ready signal in PR fallback scrape

* no-mistakes(review): Drop relay flag, stamp parent events at call sites

* no-mistakes(review): Stamp worker terminal-signal instructions, revert fm-on fixture

* no-mistakes(review): Accept optional stamp in live cmux drift guard

* no-mistakes(review): Restore original test invocation order in two suites

* no-mistakes(review): Strip only well-formed numeric status time tags

* no-mistakes(document): Drop stale unstamped PR-ready line spelling from channel doc

* no-mistakes(review): Stamp agy spawn-failure status lines with event time

* fix(bin): normalize status event times in-shell and freeze the budget test clock

Two paths made a status event's emission time cost more than it should.

The captain-relevance fallback piped every line through awk to drop a
well-formed `[at=<epoch>]` tag before matching, so a supervisor sweep paid a
fork per line just to prepare a regex match. Shell parameter expansion does the
same strip with no fork, and the retry-dedup scan now reuses that one helper
instead of carrying a second copy of the rule in awk. The copies had already
drifted: the shell side stripped tags from lines with no colon, which the awk
rule left whole, so a colonless line could be mistaken for one already
recorded. One definition, checked against the awk rule it replaces over the
edge cases and a 4000-line fuzz.

tests/fm-contributions.test.sh froze its fixture clock only in exhaust mode. In
hang mode the poll set DEADLINE to the real now plus a one-second budget, and
when the second ticked before the first forge call the loop broke without ever
calling gh: forge/calls was never written and the assertion failed reading a
missing file. Freezing the clock in both modes removes the dependence on wall
time; the bounded call is still cut by the real timeout, so the observation the
test asserts still starts.

Emission time stays optional on new status records, and legacy or malformed
lines keep an unknown age.

* no-mistakes(review): Stamp ask-user escalation line and fix Kimi status assertion

* no-mistakes(document): Drop stale unstamped done-line spelling from watcher docs

* test: fold emission-time snapshot coverage into the fixture case

Drop the incidental ci.yml 18-to-19 count hunk so the PR no longer
touches workflows. Keep every emission-time assertion by folding it
into test_fixture_snapshot_json.

* no-mistakes(review): replace brief date substitution with epoch placeholder; drop emitted_at_epoch

* no-mistakes(review): align untimed normalizer with epoch parser; tolerate placeholder stamp in PR scrape

* no-mistakes(review): strip undelimited at-tags; correct brief stamp header

* no-mistakes(review): normalize stamps at both captain-regex sites; restore mtime freshness

* no-mistakes(review): strip colon-bearing stamps for relevance; fix headers and test oracles

* no-mistakes(review): narrow escalation match to stamp tolerance; pin note verb

* no-mistakes(review): read note and key past colon-bearing stamps

* test(status): keep inactive reconcile assertions stamp-tolerant

These two oracles were made stamp-tolerant while resolving one of the
branch's merges from main. The rebase drops merge commits, so that
adaptation was lost and both assertions went back to matching an exact
substring that a stamped line no longer contains: the tag lands before
the colon, so "failed [key=k]: ..." is now "failed [key=k] [at=N]: ...".
Strip a well-formed tag before matching, as the branch's other oracles do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* no-mistakes(review): unstamp fold colon tests; reserve stamp width in cap

* no-mistakes(document): correct stale unstamped status-line spellings in docs

* no-mistakes(document): quote brief-test literals for lint; correct stamp-helper contract comments

* no-mistakes(ci): rename subshell-local epoch in delivery-race stub

The serialization test overrides fm_pending_reply_mark_delivered inside a
(..) subshell. Its `epoch` local collided with the same name in
status_line_at_epoch/status_stamp_line, which this branch added and this
suite now calls at top level, so ShellCheck 0.11.0 reported SC2030 and
failed Lint 2. The stub already prefixes its other locals with `pending_`
for the same reason; `epoch` was the leftover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- FM_DISCORD_ALLOW_DMS was exported but never checked in fm-discord-poll.js;
  DMs were ingested unconditionally. Now skipped when the flag is false.
- The poller fetched only the newest 10 messages with no cursor, so a burst
  of 11+ messages between polls could permanently skip an older mention.
  Now persists a per-channel last-seen message id and polls with after=.
- Multi-chunk replies in fm-discord-reply.js had no per-chunk progress, so a
  retry after a partial failure would repost already-delivered chunks. Now
  persists send progress per request id and resumes from the first
  unsent chunk, clearing the record on full completion.
@Ivory2024
Ivory2024 force-pushed the fm/firstmate-discord-selfhosted-relay-20260920 branch from 49901bc to 214c664 Compare September 20, 2026 09:59
irene added 8 commits September 20, 2026 19:32
….omo evidence files

.omo/evidence/*.md were pipeline review artifacts accidentally committed;
add .omo/ to .gitignore to stop recurrence.
…ip check, zellij tab_id) over the simpler earlier version, keep [at=] status-line stripping in resolve-key tests, classify restored .omo evidence files
fm-x-dismiss.sh now sources fm-discord-lib.sh and calls
fm_discord_is_selfhosted_request, which reads the context registry and
triggers fmx_context_registry_prune as a side effect. The dismiss call
in this test had no FMX_NOW_OVERRIDE, so prune ran against the real
wall clock, saw the offer marker's FMX_NOW_OVERRIDE-stamped recorded_at
as ~2.8 years old, and deleted it, causing a spurious re-offer. Give the
dismiss call the same mocked clock as the surrounding poll calls.
@Ivory2024
Ivory2024 merged commit 5879ec3 into main Sep 20, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants