docs(cli): row 45 round 1 review, changes on #1527 (darkwing)

R1: the guarded close send at host.mjs:144 and :150 can still fail with
EPIPE after a broker SIGKILL; reproduced, fix is a send callback.
R2: give-up lands 7.5 min after the first refusal, against decision 72's
reason; Sage's ruling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 08:29:50 -05:00
co-authored by Claude Opus 5.5
parent 9e172fb494
commit 1a481a1f4d
22 changed files with 1191 additions and 0 deletions
@@ -0,0 +1,75 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (152.833528ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (253.204757ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (230.195608ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (147.931522ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (139.478882ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (127.121696ms)
✔ task action subjects and linked decision trail are complete and ordered (134.92116ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (87.179284ms)
✔ business isolation includes inherited object names and cross-business message references (156.148481ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (335.181583ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (136.013068ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (292.411816ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (154.269246ms)
✔ both arbiters require human resolution when their cross-role route is themselves (238.468871ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.371989ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (5.132428ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.896412ms)
✔ expiry refuses use and env references never become client data (0.970971ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.131369ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.555314ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.688254ms)
✔ process reader gets own kernel identity without exposing environment values (1.472508ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.748135ms)
✔ real pid 1 remains inspectable when its environment is protected (0.329293ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (178.90046ms)
✔ missing and foreign-business citations refuse and roll back message and grant (180.432133ms)
✔ cross-role sends still need a matching resolved decision and consume it once (227.313243ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (178.027343ms)
✔ startup token refusal returns safe code without value or partial listening broker (34.517972ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (178.528872ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (159.949424ms)
✔ trusted host registers later launches; socket clients never have a registration verb (156.500267ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (37.307475ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (130.384853ms)
✔ v3b prototype refusals, views and append-only mutations (924.526396ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (216.848438ms)
✔ another run cannot consume an approval; a failed check leaves it usable (223.767835ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (145.461964ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (261.842401ms)
✔ class drift gated to cross-role refuses before consumption (175.76339ms)
✔ class drift cross-role to gated refuses before consumption (164.286145ms)
✔ class drift gated to within-role refuses before consumption (191.901645ms)
✔ class drift cross-role to within-role refuses before consumption (263.283898ms)
✔ class drift within-role to gated refuses before consumption (189.475732ms)
✔ class drift within-role to cross-role refuses before consumption (306.60344ms)
✔ message.send consumes approval and prevents a later send or authorize (224.945388ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (235.979713ms)
✔ creates private WAL store and excludes a second writer until explicit close (100.304159ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (157.00321ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (189.877316ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (149.921272ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (88.896272ms)
✔ async transactions refuse before invoking their function (77.889486ms)
✔ recordTask keeps sync reads and a role write apart (142.117111ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (105.467977ms)
✔ a bad entry refuses the whole record (100.606342ms)
✔ taskView reads the projection for one business (119.620223ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (213.506645ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (385.281181ms)
✔ without an adapter the server refuses every task verb (151.701103ms)
✔ the runtime refuses an invalid adapter and closes a valid one (190.211229ms)
✔ the process loads the S3 adapter from plain-data trackers (325.615938ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (141.859527ms)
✔ two wire claims serialize; a lost reply never automatically retries (161.729405ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (112.030645ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.476646ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (113.050134ms)
ℹ tests 67
ℹ suites 0
ℹ pass 67
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2677.622783
@@ -0,0 +1,70 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (128.992405ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (117.490817ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (98.507257ms)
✔ decide prints a declining choice as declining (100.856708ms)
✔ an unknown outcome is reported once and never resent (79.93514ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (84.698884ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (77.810085ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (71.306799ms)
✔ every human command refuses inside an agent run before it touches the bus (80.918075ms)
✔ usage errors exit 4; no business and no host is a usage error (89.376259ms)
✔ agents and tasks print through the broker (61.524828ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.014781ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (54.500379ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (49.252327ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (41.839281ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (39.169396ms)
✔ a business without tracker.baseUrl gets no trackers entry (34.308217ms)
✔ an unknown business and a broken system config refuse with exit 3 (68.034625ms)
✔ empty views say so (0.930745ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.26968ms)
✔ tasks print the tracker fields the snapshot carries (0.253035ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (927.218884ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (246.423158ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (134.283061ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (186.372289ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (166.112833ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (116.731548ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (27.154295ms)
✔ bus stop refuses to signal a live pid that is not a bus host (204.018698ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (207.119454ms)
✔ bus start refuses with exit 3 without a notifier config (93.241918ms)
✔ bus start runs until bus stop; status reports it while it runs (660.045857ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.485268ms)
✔ zoned uses the IANA zone across DST (25.966392ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (117.001245ms)
✔ two blocking decisions get two DMs with different nonces (108.459933ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.240469ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (99.274316ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (94.770009ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (95.556195ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (85.846962ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (97.798888ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (70.717866ms)
✔ an inbox read failure is logged and the next poll retries (1.098127ms)
✔ no Discord id reaches the journal or the log (95.167879ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (17.279417ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (33.633015ms)
✔ the journal: a whole file that is one torn line truncates to empty (24.026958ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.221119ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (2.04365ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (9.278689ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.820085ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.73875ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.447181ms)
✔ digest content stays within Discord's 2000 characters (0.397856ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.132403ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (399.577734ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (39.629142ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2175.205675ms)
✔ busExit and refuseInsideAgent (0.394553ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3125.865498
@@ -0,0 +1,186 @@
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.287609ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.772652ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.552754ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.555286ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (20.057112ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (8.396026ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (9.723808ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.796714ms)
✔ authorize: open channel, listed user (1.753435ms)
✔ authorize: wrong guild (0.307864ms)
✔ authorize: no guild (DM) (0.156238ms)
✔ authorize: unlisted channel (0.195522ms)
✔ authorize: unknown channel, no info (0.471498ms)
✔ authorize: thread of listed parent (0.176404ms)
✔ authorize: thread of unlisted parent (0.156748ms)
✔ authorize: text channel that is not a thread and not listed (0.126683ms)
✔ authorize: unlisted user (0.845342ms)
✔ authorize: no author (0.244026ms)
✔ authorize: bot author (listed id, bot flag) (0.156831ms)
✔ authorize: system author (0.104572ms)
✔ authorize: the bot itself (0.079311ms)
✔ authorize: webhook (0.081302ms)
✔ authorize: mention channel without mention (0.115263ms)
✔ authorize: mention channel with bot mention (0.124063ms)
✔ authorize: mention channel with @everyone only (0.087543ms)
✔ authorize: mention channel mentioning someone else (0.077848ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.107482ms)
✔ authorize: private thread under mention channel, mentioned (0.093787ms)
✔ authorize: private thread under mention channel, not mentioned (0.077171ms)
✔ authorize: thread in another guild per channel info (0.612302ms)
✔ authorize: not an object (0.106806ms)
✔ authorize: no id (0.071598ms)
✔ authorize: oversize content is accepted and flagged (0.068909ms)
✔ authorize: exactly the limit is not oversize (0.073781ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.47576ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.155582ms)
✔ binding: a complete binding validates and is frozen (2.913558ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.597632ms)
✔ binding: empty allowlists refuse (0.508287ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.214646ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.714821ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.389002ms)
✔ binding: file must be 0600, regular, not a symlink (1.865351ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (2.327218ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (139.583067ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.16623ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (451.80389ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (251.118956ms)
✔ cli: run refuses when STOP is present, before any network use (151.42242ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.927023ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (2.128224ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.498256ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.343511ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.559849ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.443483ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.662722ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (5.324864ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.960397ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (1.827597ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (0.805618ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (0.830627ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (45.216245ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.831916ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.13028ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (6.004927ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (5.518136ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.563135ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (1.953837ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.771237ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.982737ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.489582ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.965896ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.795453ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.717994ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.471792ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.785764ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.934387ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.179902ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.384167ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.405999ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.804551ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.72045ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.46632ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (60.580894ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (56.250412ms)
✔ engine: one prompt, one turn, text and usage come back (40.135655ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (368.284702ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (254.224904ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (108.530206ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (235.955232ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (147.016453ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (214.01221ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.725649ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.447137ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.537916ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (438.07351ms)
✔ engine: a malformed JSONL line fails the turn, not the process (33.755853ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.614852ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (3.739185ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.763493ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.562394ms)
✔ gateway: op 9 resumable resumes (0.391877ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.890443ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.565723ms)
✔ gateway: close() is final and unparseable frames are ignored (0.461188ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (72.822525ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (77.929389ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (111.262176ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.361344ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (126.273241ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (144.978571ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (147.237569ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (255.510634ms)
✔ git: push pushes the named branch only and reports up to date (96.44619ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (133.677295ms)
✔ git: the credential helper answers get over https from a private file and nothing else (265.953323ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (989.400207ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.711555ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (89.866137ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.22667ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.39465ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (73.610237ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (433.29986ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.493108ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (29.447233ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.380867ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (49.07009ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (178.425628ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (109.818702ms)
✔ notices: a kind is recorded per UTC day and found again (0.689855ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.490802ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.439216ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.027346ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (48.172081ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (45.799708ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (54.846259ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (94.896234ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (906.249937ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.631332ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (6.397403ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.770197ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.888489ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.491862ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.2789ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.569504ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.636556ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.043092ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (21.577843ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.99296ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.530728ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.552947ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (1.199436ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.951558ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1012.396434ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.545227ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (7.299134ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.701013ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.250537ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (3.259723ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.12856ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.219112ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.417539ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.907826ms)
✔ tools: listing and search caps hold (11.441719ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.88857ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.480164ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.482261ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.883579ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (7.28872ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.245284ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (3.132691ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.177413ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.122877ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1029.829589ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (6.112211ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.349116ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (4.718867ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (4.136913ms)
ℹ tests 178
ℹ suites 0
ℹ pass 178
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2776.496022
@@ -0,0 +1,59 @@
✔ the boot config is checked before anything starts (89.046617ms)
✔ a business with no tracker entry refuses task verbs (137.89565ms)
✔ credential.expiring and .expired are recorded once per instance (223.665838ms)
✔ a token file that changes on disk records credential.changed (118.085583ms)
✔ autostart polls, reconciles and retries a startup the tracker was down for (143.900704ms)
✔ a refusal a restart must clear is not retried by the poll (94.213916ms)
✔ a poll that fires while two are queued is dropped (110.267607ms)
✔ close waits for a running verb and refuses one that has not started (219.79293ms)
✔ the bundled Vikunja is the pinned upstream image the runbook names (0.877648ms)
✔ every published port is on 127.0.0.1, and no secret is in the file (0.328584ms)
✔ the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent (410.040731ms)
✔ the recorded task bodies pass the checks S3 applies to every read (0.544635ms)
✔ the client works against the fake over real HTTP with the platform fetch (247.28967ms)
✔ a correct install starts, and the first reconcile records tasks that already exist (133.366137ms)
✔ verbs refuse while a business is starting and after startup refused it (150.426664ms)
✔ startup refuses a token that can do more than its role needs (386.043858ms)
✔ startup refuses an unsupported version and flags an untested one (327.770343ms)
✔ startup refuses a board that the runbook did not install (365.179598ms)
✔ startup refuses a project the sync bot cannot read (80.271503ms)
✔ startup refuses a configured label the pm bot cannot see (98.149305ms)
✔ startup refuses an expired credential and a missing sync credential (182.597863ms)
✔ an unreachable tracker refuses with tracker-unavailable (89.255677ms)
✔ an edit in the UI is recorded once, with the fields that changed (243.814564ms)
✔ a move between open buckets is seen on the board, though updated does not change (177.384973ms)
✔ a person's comment is counted and a bot's is not (295.14635ms)
✔ the hourly reconcile catches a comment through comment_count (234.667187ms)
✔ a task closed in the UI leaves the open view with its done bucket (407.706342ms)
✔ a task that leaves the board is recorded as deleted, moved or out of reach (271.896638ms)
✔ a poll that read before a verb wrote does not overwrite the verb (178.02935ms)
✔ a tracker fault during a tick is reported and the next tick catches up (148.642218ms)
✔ a malformed answer refuses the tick with tracker-shape (134.707598ms)
✔ no token value reaches the database, the log or a refusal (329.495843ms)
✔ the first look at a task counts only comments inside the window (157.354652ms)
✔ task.create needs a recorded human request and a requirement id (226.947608ms)
✔ only labels named in the business file can be written (222.046559ms)
✔ task.schedule sets and clears a due date and relations (301.619743ms)
✔ assign and reassign move the role bots and record task.assigned (332.482709ms)
✔ task.update.assigned is for the assignee and records task.state (304.807889ms)
✔ a wrong expected digest records task.conflict and writes nothing (185.762624ms)
✔ a cross-role verb needs a resolved decision, used once (245.8395ms)
✔ task.close needs a verdict; after it every verb refuses with task-done (230.244994ms)
✔ a lost answer is settled by a re-read and never retried (285.789995ms)
✔ a create whose answer is lost is reported uncertain, and the poll finds the task (221.195028ms)
✔ a task the sync bot cannot read refuses and records nothing (125.914971ms)
✔ verbs and polls for one business run one at a time (186.434851ms)
✔ a due date with milliseconds is written to the second (204.76624ms)
✔ every write landed and the final read failed: the verb succeeds and records what it wrote (126.173284ms)
✔ some writes landed and the final read failed: write-uncertain, and nothing is recorded (92.670246ms)
✔ a create whose final read fails succeeds and records task.created (149.29247ms)
✔ an edit between the last write and the final read shows as external on the next poll (146.870016ms)
✔ task.created is recorded when a later label write fails (160.031026ms)
ℹ tests 51
ℹ suites 0
ℹ pass 51
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3865.142035
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,23 @@
// Row 45 probe: how long after the first definite refusal the notifier
// gives up, with the real backoff and a 30 s poll. Fake clock, fake inbox
// with one blocking decision, a direct.send that always refuses with 403.
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
const W = process.argv[2];
const { createNotifier, POLL_MS } = await import(`${W}/cli/src/notifier.mjs`);
const { RestOutcome } = await import(`${W}/discord/src/rest.mjs`);
const root = mkdtempSync(join(process.env.TMPDIR, "dw-r45-g-"));
let t = Date.parse("2026-10-09T12:00:00.000Z"); // 07:00 Chicago, before the digest hour
const sends = [];
const logs = [];
const n = createNotifier({
business: "acme", dataRoot: root,
inbox: async () => [{ id: "dec-0001-aaaa", blocking: true, action: "approve", question: "q", options: [{ key: "yes", text: "yes" }], created: "2026-10-09T11:00:00.000Z", requester: "r" }],
direct: { send: async () => { sends.push(t); throw new RestOutcome("refused", "dm: refused", { status: 403 }); } },
now: () => new Date(t), log: (l) => logs.push(`${new Date(t).toISOString()} ${l}`),
});
const t0 = t;
for (let i = 0; i < 2000 && !logs.some((l) => l.includes("gave up")); i++) { await n.tick(); t += POLL_MS; }
console.log(`poll ${POLL_MS / 1000} s; sends at minutes after the first: ${sends.map((s) => ((s - t0) / 60000).toFixed(1)).join(", ")}`);
for (const l of logs) console.log(l);
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,6 @@
poll 30 s; sends at minutes after the first: 0.0, 0.5, 1.5, 3.5, 7.5
2026-10-09T12:00:00.000Z notify: dm refused (HTTP 403); retry in 30 s
2026-10-09T12:00:30.000Z notify: dm refused (HTTP 403); retry in 60 s
2026-10-09T12:01:30.000Z notify: dm refused (HTTP 403); retry in 120 s
2026-10-09T12:03:30.000Z notify: dm refused (HTTP 403); retry in 240 s
2026-10-09T12:07:30.000Z notify: dm dec-0001 refused 5 times; gave up, not retried
@@ -0,0 +1,2 @@
rc=0 TALLY {"SIG":"SIGKILL","SPIN":2,"runs":40,"rejected":{"1: notifier exited (null) before it replied":37,"undefined: write EPIPE":3},"closeSends":9,"errorEvents":{}} ℹ fail 0 unhandled=0
rc=0 TALLY {"SIG":"SIGKILL","SPIN":2,"runs":40,"rejected":{"1: notifier exited (null) before it replied":38,"undefined: write EPIPE":2},"closeSends":17,"errorEvents":{}} ℹ fail 0 unhandled=0
@@ -0,0 +1,4 @@
TALLY {"SIG":"SIGKILL","SPIN":0,"runs":40,"rejected":{"1: notifier exited (null) before it replied":40},"closeSends":37,"errorEvents":{}}
ℹ fail 0
TALLY {"SIG":"SIGKILL","SPIN":0,"runs":40,"rejected":{"1: notifier exited (null) before it replied":40},"closeSends":40,"errorEvents":{}}
ℹ fail 0
@@ -0,0 +1,62 @@
// Row 45 probe: the :144 window through startHost. Both children die at the
// same moment as the start send (as a cgroup-wide kill would do), so the
// broker can be dead while broker.connected is still true. Counts 'error'
// events on the broker and close sends; does not change host.mjs.
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { subscribe, unsubscribe } from "node:diagnostics_channel";
import { bootConfig, loadSystem } from "/home/jwoltje/darkwing-scratch/r45/wt/packages/cli/src/config.mjs";
import { startHost } from "/home/jwoltje/darkwing-scratch/r45/wt/packages/cli/src/host.mjs";
import { makeDeployment } from "/home/jwoltje/darkwing-scratch/r45/wt/packages/discord/tests/helpers.mjs";
import { fixture, tmp } from "/home/jwoltje/darkwing-scratch/r45/wt/packages/cli/tests/helpers.mjs";
const N = Number(process.env.N ?? 30);
const SIG = process.env.SIG ?? "SIGKILL";
const SPIN = Number(process.env.SPIN ?? 0); // ms of busy-wait between the two kills
const tally = { runs: 0, rejected: {}, closeSends: 0, errorEvents: {} };
for (let i = 0; i < N; i++) {
test(`window run ${i}`, async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const children = [];
const onChild = ({ process: child }) => {
children.push(child);
const send = child.send;
};
subscribe("child_process", onChild);
t.after(() => unsubscribe("child_process", onChild));
let armed = true;
const origSends = [];
const startSpy = ({ process: child }) => {
let send;
Object.defineProperty(child, "send", { configurable: true, get: () => send, set(fn) {
send = function (m, ...rest) {
if (m?.op === "close") tally.closeSends++;
const r = fn.call(this, m, ...rest);
if (m?.op === "start" && armed) {
armed = false;
if (!process.env.NOLISTEN) children[0].on("error", (e) => (tally.errorEvents[e.code] = (tally.errorEvents[e.code] ?? 0) + 1));
children[0].kill(SIG);
const until = performance.now() + SPIN;
while (performance.now() < until);
// ZOMBIE: wait until the kernel has the broker dead (state Z), so its end of the channel is closed.
if (process.env.ZOMBIE) while (!/\) Z /.test(readFileSync("/proc/" + children[0].pid + "/stat", "utf8")));
children[1].kill(SIG);
}
return r;
};
} });
};
subscribe("child_process", startSpy);
t.after(() => unsubscribe("child_process", startSpy));
const started = startHost({ boot, business: "acme", notifier: { binding: "test-seat" }, log: () => {} });
const r = await started.then((h) => (h.close(0), "started"), (e) => `${e.exitCode}: ${e.message.replace(/\(\d+\)/, "(n)")}`);
await new Promise((r) => setTimeout(r, 20));
tally.runs++;
tally.rejected[r] = (tally.rejected[r] ?? 0) + 1;
});
}
test("tally", () => console.log("TALLY " + JSON.stringify({ SIG, SPIN, ...tally })));
@@ -0,0 +1,10 @@
TALLY {"SIG":"SIGKILL","SPIN":0,"runs":30,"rejected":{"1: notifier exited (null) before it replied":30},"closeSends":30,"errorEvents":{}}
ℹ fail 0
TALLY {"SIG":"SIGKILL","SPIN":1,"runs":30,"rejected":{"1: notifier exited (null) before it replied":30},"closeSends":28,"errorEvents":{}}
ℹ fail 0
TALLY {"SIG":"SIGKILL","SPIN":2,"runs":30,"rejected":{"undefined: write EPIPE":3,"1: notifier exited (null) before it replied":27},"closeSends":10,"errorEvents":{"EPIPE":3}}
ℹ fail 0
TALLY {"SIG":"SIGKILL","SPIN":5,"runs":30,"rejected":{"1: notifier exited (null) before it replied":30},"closeSends":0,"errorEvents":{}}
ℹ fail 0
TALLY {"SIG":"SIGKILL","SPIN":20,"runs":30,"rejected":{"1: notifier exited (null) before it replied":30},"closeSends":0,"errorEvents":{}}
ℹ fail 0
@@ -0,0 +1,50 @@
✔ window run 0 (143.171026ms)
✔ window run 1 (133.810356ms)
✔ window run 2 (155.634019ms)
✔ window run 3 (136.929067ms)
✔ window run 4 (120.744941ms)
✔ window run 5 (120.560452ms)
✔ window run 6 (118.724466ms)
✔ window run 7 (124.222052ms)
✔ window run 8 (121.623053ms)
✔ window run 9 (116.433491ms)
✔ window run 10 (129.175005ms)
✔ window run 11 (154.780448ms)
✔ window run 12 (160.102241ms)
✔ window run 13 (135.511571ms)
✔ window run 14 (129.921994ms)
✔ window run 15 (121.551405ms)
✔ window run 16 (130.75576ms)
✔ window run 17 (131.680654ms)
✔ window run 18 (128.039242ms)
✔ window run 19 (126.574765ms)
✔ window run 20 (130.179706ms)
✔ window run 21 (131.368879ms)
✔ window run 22 (140.212059ms)
✔ window run 23 (270.914529ms)
✔ window run 24 (120.453246ms)
✔ window run 25 (149.407069ms)
✔ window run 26 (170.685941ms)
✔ window run 27 (143.691302ms)
✔ window run 28 (124.184205ms)
✔ window run 29 (124.902658ms)
✔ window run 30 (137.305275ms)
✔ window run 31 (152.064224ms)
✔ window run 32 (129.255746ms)
✔ window run 33 (120.036076ms)
✔ window run 34 (138.572847ms)
✔ window run 35 (124.159526ms)
✔ window run 36 (127.109452ms)
✔ window run 37 (120.579976ms)
✔ window run 38 (122.851265ms)
TALLY {"SIG":"SIGKILL","SPIN":2,"runs":40,"rejected":{"1: notifier exited (null) before it replied":37,"undefined: write EPIPE":3},"closeSends":9,"errorEvents":{}}
✔ window run 39 (129.657296ms)
✔ tally (0.316864ms)
ℹ tests 41
ℹ suites 0
ℹ pass 41
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 23507.594052
@@ -0,0 +1,50 @@
✔ window run 0 (147.702754ms)
✔ window run 1 (142.551414ms)
✔ window run 2 (152.261498ms)
✔ window run 3 (182.876908ms)
✔ window run 4 (153.329128ms)
✔ window run 5 (150.12033ms)
✔ window run 6 (131.930439ms)
✔ window run 7 (173.578792ms)
✔ window run 8 (165.037345ms)
✔ window run 9 (133.484542ms)
✔ window run 10 (139.326277ms)
✔ window run 11 (179.647357ms)
✔ window run 12 (164.515519ms)
✔ window run 13 (146.580755ms)
✔ window run 14 (131.466295ms)
✔ window run 15 (138.055738ms)
✔ window run 16 (151.067218ms)
✔ window run 17 (131.15351ms)
✔ window run 18 (123.362378ms)
✔ window run 19 (131.656281ms)
✔ window run 20 (152.522083ms)
✔ window run 21 (134.347585ms)
✔ window run 22 (133.373477ms)
✔ window run 23 (132.474431ms)
✔ window run 24 (128.867977ms)
✔ window run 25 (127.188446ms)
✔ window run 26 (138.181564ms)
✔ window run 27 (140.818199ms)
✔ window run 28 (129.031281ms)
✔ window run 29 (150.838863ms)
✔ window run 30 (142.005756ms)
✔ window run 31 (130.383943ms)
✔ window run 32 (130.43571ms)
✔ window run 33 (169.551714ms)
✔ window run 34 (135.95131ms)
✔ window run 35 (137.700265ms)
✔ window run 36 (136.336047ms)
✔ window run 37 (143.663155ms)
✔ window run 38 (133.247337ms)
TALLY {"SIG":"SIGKILL","SPIN":2,"runs":40,"rejected":{"1: notifier exited (null) before it replied":38,"undefined: write EPIPE":2},"closeSends":17,"errorEvents":{}}
✔ window run 39 (144.037587ms)
✔ tally (0.338237ms)
ℹ tests 41
ℹ suites 0
ℹ pass 41
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 24280.478461
@@ -0,0 +1,10 @@
written: /home/jwoltje/darkwing-scratch/tmp/tmp.u27G38M1Cw/[email protected]
next, for one business (one per data root):
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger jwoltje
@@ -0,0 +1,37 @@
// Row 45 probe: openJournal across directory and file states. Prints the
// error class, exit code and message (paths shortened to <tmp>) per case.
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const { openJournal } = await import(process.argv[2]);
const root = mkdtempSync(join(process.env.TMPDIR, "dw-r45-j-"));
const show = (name, fn) => {
let r;
try { fn(); r = "opens"; } catch (e) { r = `${e.constructor.name} exit=${e.exitCode ?? "-"} code=${e.code ?? "-"}: ${e.message.replaceAll(root, "<tmp>")}`; }
console.log(`${name.padEnd(34)} ${r}`);
};
const j = (d) => join(d, "sent.jsonl");
let n = 0; const fresh = () => join(root, `c${n++}`);
show("new dir", () => openJournal(j(join(fresh(), "acme"))));
{ const p = fresh(); mkdirSync(p, { mode: 0o500 }); show("missing dir, parent 0500", () => openJournal(j(join(p, "acme")))); chmodSync(p, 0o700); }
{ const d = fresh(); mkdirSync(d, { mode: 0o500 }); show("dir 0500", () => openJournal(j(d))); chmodSync(d, 0o700); }
{ const d = fresh(); mkdirSync(d, { mode: 0o300 }); show("dir 0300 (no read)", () => openJournal(j(d))); chmodSync(d, 0o700); }
{ const d = fresh(); mkdirSync(d, { mode: 0o755 }); chmodSync(d, 0o755); show("dir 0755", () => openJournal(j(d))); }
{ const t = fresh(); mkdirSync(t, { mode: 0o700 }); const d = fresh(); symlinkSync(t, d); show("dir symlink to 0700", () => openJournal(j(d))); }
{ const d = fresh(); symlinkSync(join(root, "nowhere"), d); show("dir dangling symlink", () => openJournal(j(d))); }
{ const d = fresh(); writeFileSync(d, ""); show("dir path is a file", () => openJournal(j(d))); }
{ const p = fresh(); writeFileSync(p, ""); show("parent is a file", () => openJournal(j(join(p, "acme")))); }
{ const d = fresh(); mkdirSync(d, { mode: 0o700 }); writeFileSync(j(d), "", { mode: 0o400 }); show("file 0400", () => openJournal(j(d))); }
{ const d = fresh(); mkdirSync(d, { mode: 0o700 }); writeFileSync(j(d), "", { mode: 0o644 }); chmodSync(j(d), 0o644); show("file 0644", () => openJournal(j(d))); }
{ const d = fresh(); mkdirSync(d, { mode: 0o700 }); mkdirSync(j(d)); show("file path is a dir", () => openJournal(j(d))); }
const line = (o) => JSON.stringify(o) + "\n";
const at = "2026-10-09T12:00:00.000Z";
for (const [name, rec] of [
["gave-up dm", { at, kind: "dm", decision: "d1", outcome: "gave-up", messageId: null }],
["gave-up digest", { at, kind: "digest", decision: null, day: "2026-10-09", outcome: "gave-up", messageId: null }],
["refused dm status 403", { at, kind: "dm", decision: "d1", outcome: "refused", messageId: null, status: 403 }],
["refused dm status \"403\"", { at, kind: "dm", decision: "d1", outcome: "refused", messageId: null, status: "403" }],
["digest day 2026-13-45", { at, kind: "digest", decision: null, day: "2026-13-45", outcome: "confirmed", messageId: "1" }],
["at without ms", { at: "2026-10-09T12:00:00Z", kind: "dm", decision: "d1", outcome: "unknown", messageId: null }],
["dm decision 7", { at, kind: "dm", decision: 7, outcome: "confirmed", messageId: "1" }],
]) { const d = fresh(); mkdirSync(d, { mode: 0o700 }); writeFileSync(j(d), line(rec), { mode: 0o600 }); show(name, () => openJournal(j(d))); }
rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,19 @@
new dir opens
missing dir, parent 0500 CliError exit=3 code=-: notify journal directory cannot be created (EACCES): <tmp>/c1/acme
dir 0500 CliError exit=3 code=-: notify journal directory is not writable (EACCES): <tmp>/c2
dir 0300 (no read) opens
dir 0755 CliError exit=3 code=-: notify journal directory must be mode 0700 and owned by this user: <tmp>/c4
dir symlink to 0700 CliError exit=3 code=-: notify journal directory must not be a symlink: <tmp>/c6
dir dangling symlink Error exit=- code=ENOENT: ENOENT: no such file or directory, mkdir '<tmp>/c7'
dir path is a file CliError exit=3 code=-: notify journal directory must be mode 0700 and owned by this user: <tmp>/c8
parent is a file Error exit=- code=ENOTDIR: ENOTDIR: not a directory, mkdir '<tmp>/c9/acme'
file 0400 CliError exit=3 code=-: notify journal is not writable (EACCES): <tmp>/c10/sent.jsonl
file 0644 CliError exit=3 code=-: notify journal must be a regular file, mode 0600, owned by this user: <tmp>/c11/sent.jsonl
file path is a dir Error exit=- code=EISDIR: EISDIR: illegal operation on a directory, open '<tmp>/c12/sent.jsonl'
gave-up dm opens
gave-up digest CliError exit=3 code=-: notify journal line 1 is malformed (outcome): <tmp>/c14/sent.jsonl
refused dm status 403 opens
refused dm status "403" CliError exit=3 code=-: notify journal line 1 is malformed (status): <tmp>/c16/sent.jsonl
digest day 2026-13-45 opens
at without ms CliError exit=3 code=-: notify journal line 1 is malformed (at): <tmp>/c18/sent.jsonl
dm decision 7 CliError exit=3 code=-: notify journal line 1 is malformed (decision): <tmp>/c19/sent.jsonl
@@ -0,0 +1,25 @@
// Row 45 probe: SIGKILL an IPC child, then send to it after `delay` while
// child.connected may still be true. Mirrors host.mjs:144 (guard, no
// callback, no 'error' listener). One run per process so an uncaught
// 'error' shows as a crash.
// node late-send.mjs <delay: sync|tick|immediate|<ms>> [callback]
import { fork } from "node:child_process";
const [delay, cb] = process.argv.slice(2);
if (process.argv[2] === "--child") { process.on("message", () => {}); setInterval(() => {}, 1e9); }
else {
const child = fork(import.meta.filename, ["--child"], { stdio: ["ignore", "ignore", "ignore", "ipc"] });
await new Promise((r) => child.once("spawn", r));
const exited = new Promise((r) => child.once("exit", r));
await new Promise((r) => setTimeout(r, 150));
let errorEvent = null, cbErr;
if (cb) child.on("error", (e) => (errorEvent = e.code ?? e.message));
child.kill("SIGKILL");
const wait = delay === "sync" ? null : delay === "tick" ? new Promise((r) => process.nextTick(r)) : delay === "immediate" ? new Promise((r) => setImmediate(r)) : new Promise((r) => setTimeout(r, Number(delay)));
if (wait) await wait;
const connected = child.connected;
let sent = null;
if (connected) sent = cb ? child.send({ op: "close" }, (e) => (cbErr = e?.code ?? e?.message ?? null)) : child.send({ op: "close" });
await exited;
await new Promise((r) => setTimeout(r, 50));
console.log(JSON.stringify({ delay, cb: !!cb, connected, sent, cbErr, errorEvent }));
}
@@ -0,0 +1,17 @@
20 rc=0 {"delay":"sync","cb":false,"connected":true,"sent":true,"errorEvent":null}
20 rc=0 {"delay":"immediate","cb":false,"connected":true,"sent":true,"errorEvent":null}
5 rc=0 {"delay":"1","cb":false,"connected":false,"sent":null,"errorEvent":null}
6 rc=0 {"delay":"1","cb":false,"connected":true,"sent":true,"errorEvent":null}
9 rc=1 node:events:505 throw er; // Unhandled 'error' event ^ Error: write EPIPE
20 rc=0 {"delay":"2","cb":false,"connected":false,"sent":null,"errorEvent":null}
20 rc=0 {"delay":"5","cb":false,"connected":false,"sent":null,"errorEvent":null}
20 rc=0 {"delay":"20","cb":false,"connected":false,"sent":null,"errorEvent":null}
20 rc=0 {"delay":"sync","cb":true,"connected":true,"sent":true,"cbErr":null,"errorEvent":null}
20 rc=0 {"delay":"immediate","cb":true,"connected":true,"sent":true,"cbErr":null,"errorEvent":null}
9 rc=0 {"delay":"1","cb":true,"connected":false,"sent":null,"errorEvent":null}
10 rc=0 {"delay":"1","cb":true,"connected":true,"sent":true,"cbErr":"EPIPE","errorEvent":null}
1 rc=0 {"delay":"1","cb":true,"connected":true,"sent":true,"cbErr":null,"errorEvent":null}
18 rc=0 {"delay":"2","cb":true,"connected":false,"sent":null,"errorEvent":null}
2 rc=0 {"delay":"2","cb":true,"connected":true,"sent":true,"cbErr":"EPIPE","errorEvent":null}
20 rc=0 {"delay":"5","cb":true,"connected":false,"sent":null,"errorEvent":null}
20 rc=0 {"delay":"20","cb":true,"connected":false,"sent":null,"errorEvent":null}
@@ -0,0 +1,71 @@
✔ zoned uses the IANA zone across DST (25.948049ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (59.385534ms)
✔ two blocking decisions get two DMs with different nonces (48.096062ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.210986ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (56.248398ms)
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (61.968033ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (62.44892ms)
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (55.157105ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (63.63229ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (60.85209ms)
✔ an inbox read failure is logged and the next poll retries (2.854273ms)
✔ no Discord id reaches the journal or the log (52.426152ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (11.22238ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (16.275646ms)
✔ the journal: a whole file that is one torn line truncates to empty (10.011137ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.931897ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.724113ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.795814ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.37236ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.666603ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.399524ms)
✔ digest content stays within Discord's 2000 characters (0.296185ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.743409ms)
ℹ tests 23
ℹ suites 0
ℹ pass 21
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 775.811359
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:112:1
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (61.968033ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
+ []
- [
- 'notify: dm fa665b80 refused 5 times; gave up, not retried'
- ]
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r45/wt/packages/cli/tests/notifier.test.mjs:133:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [],
expected: [ 'notify: dm fa665b80 refused 5 times; gave up, not retried' ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/cli/tests/notifier.test.mjs:160:1
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (55.157105ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///home/jwoltje/darkwing-scratch/r45/wt/packages/cli/tests/notifier.test.mjs:169:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,70 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (87.288054ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (124.894773ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (98.523516ms)
✔ decide prints a declining choice as declining (108.00826ms)
✔ an unknown outcome is reported once and never resent (104.989226ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (107.087901ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (105.560433ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (76.568801ms)
✔ every human command refuses inside an agent run before it touches the bus (70.770401ms)
✔ usage errors exit 4; no business and no host is a usage error (86.601955ms)
✔ agents and tasks print through the broker (86.462971ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.689987ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (54.930524ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (40.37592ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (38.046564ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (37.51217ms)
✔ a business without tracker.baseUrl gets no trackers entry (34.569636ms)
✔ an unknown business and a broken system config refuse with exit 3 (68.077525ms)
✔ empty views say so (1.294855ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.601403ms)
✔ tasks print the tracker fields the snapshot carries (0.295079ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (905.493963ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (221.958513ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (117.652897ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (161.616615ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (162.041873ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (127.295782ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (29.576383ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.900199ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (457.108896ms)
✔ bus start refuses with exit 3 without a notifier config (136.376877ms)
✔ bus start runs until bus stop; status reports it while it runs (838.009722ms)
✔ bus-service.sh renders the unit and installs it into a given directory (54.175681ms)
✔ zoned uses the IANA zone across DST (22.698752ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (88.77406ms)
✔ two blocking decisions get two DMs with different nonces (105.817904ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.202537ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (96.428397ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (86.78883ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (111.163991ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (101.629113ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (127.796788ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (97.322386ms)
✔ an inbox read failure is logged and the next poll retries (0.727558ms)
✔ no Discord id reaches the journal or the log (93.398588ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (29.929669ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (35.184223ms)
✔ the journal: a whole file that is one torn line truncates to empty (24.722434ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (2.584468ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.653896ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (4.297408ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.371732ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.432334ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.400044ms)
✔ digest content stays within Discord's 2000 characters (0.321733ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.859204ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (381.093017ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (43.308878ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2170.945139ms)
✔ busExit and refuseInsideAgent (0.97052ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3567.991872
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,205 @@
# Row 45, S4 follow-up, round 1 review (Darkwing)
Issue #1527, request comment 26869, queue rev 207, pushed at `112071c7`.
Packet: `agents/rocko/work/s4-follow-up/`, base `521597bb`, 8 files.
Candidate manifest sha256
`b329fdcbdf8cb62659f239359570dfa9771559e3112b743df55429244769ed14`,
`build.patch` sha256
`4ed9ff61b94b4c9d2426e5703c2e6c0dbcd6bc0c64a88d59347a8b47f4933408`.
Brief: `docs/plans/2026-10-09_s4-follow-up-and-cohort.md`. Ruling: lead
decision 72. My focus, from Sage: the DM path and the host.
Verdict: **changes**, comment 26872. R1 is a one-line fix with a test. R2 needs Sage's
ruling before Rocko can act on it. Everything else in my focus holds.
## Method
- A detached worktree at `521597bb`, then `git apply --index build.patch`
and `sha256sum -c candidate-manifest.sha256`: 8 OK. After the mutants I
restored the files and checked the manifest again: 8 OK.
- I read the diff for `host.mjs`, `notifier.mjs`, `bus-service.sh`, the
cli README and the discord journal test, against decision 72.
- Probes in `probe/`, described with each finding below. They import from
my scratch worktree by absolute path, so they won't run as committed
without editing the paths.
- Two mutants: Ma removes the give-up log line, Md removes the `mkdir`
line from the install message.
Node is v26.8.1, `TMPDIR=~/darkwing-scratch/tmp`.
## Suites
| Suite | Result | File |
|---|---|---|
| `node --test 'packages/cli/tests/*.test.mjs'` | 60/60 | `out/node-cli.txt` |
| `node --test 'packages/discord/tests/*.test.mjs'` | 178/178 | `out/node-discord.txt` |
| `node --test 'packages/bus/tests/*.test.mjs'` | 67/67 | `out/node-bus.txt` |
| `node --test 'packages/tasks/tests/*.test.mjs'` | 51/51 | `out/node-tasks.txt` |
| `scripts/test-discord.sh` | 66 passed, 0 failed | `out/test-discord.txt` |
Sage's gate covers the rest.
## R1: the close send at host.mjs:144 and :150 can still fail (changes)
Rocko's packet calls the window after a broker SIGKILL theoretical. I can
reproduce it. The guard `if (broker.connected)` helps only once Node has
seen the channel close. Between the broker's death and that moment,
`connected` is still true, and `send()` fails asynchronously with
`EPIPE`. With no callback, Node reports that failure as an `'error'`
event on the child.
`probe/late-send.mjs` forks a child, SIGKILLs it, waits, then sends with
no `'error'` listener. 20 runs per row, output `probe/late-send.txt`:
| Delay after kill | No callback | With `() => {}` callback |
|---|---|---|
| sync, setImmediate | 20/20 sent | 20/20 sent |
| 1 ms | 9 crash on an unhandled `'error'` (`write EPIPE`), 6 sent, 5 not connected | 10 `EPIPE` to the callback, 0 `'error'` events, 0 crashes |
| 2 ms | 20 not connected | 2 `EPIPE` to the callback, 18 not connected |
| 5 ms, 20 ms | 20 not connected | 20 not connected |
`probe/host-window.test.mjs` goes through `startHost`. When the host sends
`start`, it SIGKILLs the broker, busy-waits SPIN ms, then SIGKILLs the
notifier, the way a cgroup-wide kill would land.
| Run | Result | File |
|---|---|---|
| SPIN 0, 1 | 30/30 reject with the notifier error, no `'error'` events | `probe/host-window.txt` |
| SPIN 2, probe listener on the broker | 3/30 reject with `write EPIPE`, no exit code | `probe/host-window.txt` |
| SPIN 2, no listener, two batches of 40 | 3/40 and 2/40 reject with `write EPIPE`, no exit code, 0 unhandled | `probe/hw-nolisten-1.txt`, `probe/hw-nolisten-2.txt` |
| SPIN 5, 20 | 0 close sends | `probe/host-window.txt` |
| broker stopped but not reaped | 37/40 and 40/40 close sends, no errors | `probe/host-window-zombie.txt` |
The process doesn't crash through `startHost`, because `ended(broker)`
awaits `once(broker, "exit")`, and `once` rejects on an `'error'` event.
That catch is a side effect. What comes out is wrong, though.
`startHost` rejects with a raw `Error: write EPIPE` instead of the
notifier error it was about to rethrow. `cli.mjs:207` rethrows anything
that isn't a `CliError`, so the CLI dies with a stack trace and exit 1,
and the notifier's error never reaches the operator. S6 embeds `startHost` in process,
and a bare send there without the `once` around it is the 1 ms crash
row above.
The fix keeps the guard and adds a callback at both sites:
```js
if (broker.connected) broker.send({ op: "close" }, () => {});
```
With a callback, Node passes the error to it and emits no `'error'`
event. The late-send probe shows 0 `'error'` events and 0 crashes with
it.
A deterministic test is better than a timing probe. Wrap the broker's
`send` for `close` only, so it calls the callback with an `EPIPE` error if
one was given, and otherwise emits `'error'` on the child on the next
tick. Then assert that `startHost` rejects with the notifier's
`CliError`. Without the callback the test sees `write EPIPE` and fails.
`close()` at `host.mjs:184` and `:188` has the same window behind the
same guard. That code predates this row. I'd add the callback there too
while the lines are open. Rocko or Sage can decide whether to fold it in
or leave it out.
## R2: give-up after 7.5 minutes against decision 72's reason (Sage)
The counting is right. The timing doesn't match the reason decision 72
gives for five: "Five is enough to ride out a binding typo fixed within a
few hours."
`probe/giveup-time.mjs` runs the notifier on a fake clock against a
transport that answers 403 every time. Output, `probe/giveup-time.txt`:
```
poll 30 s; sends at minutes after the first: 0.0, 0.5, 1.5, 3.5, 7.5
2026-10-09T12:00:00.000Z notify: dm refused (HTTP 403); retry in 30 s
2026-10-09T12:00:30.000Z notify: dm refused (HTTP 403); retry in 60 s
2026-10-09T12:01:30.000Z notify: dm refused (HTTP 403); retry in 120 s
2026-10-09T12:03:30.000Z notify: dm refused (HTTP 403); retry in 240 s
2026-10-09T12:07:30.000Z notify: dm dec-0001 refused 5 times; gave up, not retried
```
Backoff starts at 30 s and doubles, so the five tries span 7.5 minutes.
In `discord/src/rest.mjs` any non-2xx status under 500 except 429 is a
definite refusal, so 401 for a bad token counts too. A wrong recipient
or a bad token then gives up on every open blocking DM within about 8
minutes, for good. After that the only signal is the digest mark, and
nothing re-arms a decision that gave up.
Either the code or the reason has to change. Two ways:
1. Space definite refusals at the 30-minute cap. Four gaps make about
2 hours, which is "a few hours" if you squint. Three hours would need
a longer cap or a sixth try.
2. Amend decision 72's reason to say minutes. Then the operator's
recovery path after a fixed typo is worth one line somewhere, since
the DMs that gave up don't come back.
This is Sage's ruling. I'd take option 1. A typo fixed over lunch
shouldn't silently cost every pending blocking DM.
## What holds
- **F2 counting.** Only a `dm` line with outcome `refused` and an integer
status from 400 to 499, other than 429, counts. The journal rebuilds
`refusals` and `gaveUp` on open. `tick` skips decisions that gave up.
The notifier test for a crash between the fifth refusal and the
give-up line covers recovery. Mutant Ma (no give-up log line) fails two
notifier tests, "five definite refusals stop a DM" and "a crash between
the fifth refusal", in `probe/mut-Ma-nolog.txt`. That agrees with
Rocko's mutant table.
- **The digest line.** It shows `[blocking, DM refused, not retried]` for
a decision that gave up.
- **Journal types (J4).** Each field is type-checked. A bad line refuses
with `notify journal line N is malformed (<field>)` and exit 3. A
`gave-up` outcome on a digest line, a string status, an `at` without
milliseconds and a numeric decision each refuse, in
`probe/journal-paths.txt`.
- **Journal error paths.** A parent at 0500, a directory at 0500, a 0755
directory, a symlinked directory and a 0400 journal each refuse with a
`CliError` and exit 3. A directory at 0300 opens, which is right, since
the journal never lists it.
- **The guards.** Against a broker that is fully gone, `connected` is
false and nothing is sent. Against one that is stopped but not reaped,
the send goes through, 77/80 with no errors.
- **The install message.** It now prints
`mkdir -m 0700 -p <dataRoot>/notify/<business>` with the reason, in
`probe/install-msg.txt`. That closes my round 2 note 1 from row 39.
- **The discord journal test's dead pid.** It now takes a pid from a child
that `spawnSync` has already reaped, and `pidAlive` treats `EPERM` as
alive. No fixed `2 ** 22` pids remain. `pid_max` on this host is
4194304, which is the case the old pid could hit.
## Notes (not blocking)
1. Three journal paths still throw a raw error with no exit code: a
dangling symlink in place of the directory (`ENOENT` from `mkdir`), a
parent that is a file (`ENOTDIR`) and a `sent.jsonl` that is a
directory (`EISDIR`). The host still exits 3 through the notifier's
refusal. The dangling symlink is a symlinked directory, which brief
item 5 covers, so an `lstat` before `mkdir` would make it the symlink
message.
2. No test asserts the install message's `mkdir` line. Mutant Md removes
it, and cli 60/60 and test-discord 66/0 still pass
(`probe/mut-Md-nomkdir.txt`, `probe/mut-Md-test-discord.txt`).
3. The `day` check on a digest line accepts `2026-13-45`. It's a shape
check, not a date check. That's fine if intended.
4. The cli README sentence on the digest mark has a comma list that reads
two ways, and a long line. A small edit.
5. `accessSync(dir, W_OK | X_OK)` is advisory, and the directory can
change before `open`. `open` still refuses, so it's only the message
that could differ.
## Files
- `review-r1.md`, this file.
- `out/`: the suite outputs.
- `probe/late-send.mjs`, `probe/late-send.txt`: send after SIGKILL, with
and without a callback.
- `probe/host-window.test.mjs` and its outputs (`host-window*.txt`,
`hw-nolisten-*.txt`): the same window through `startHost`.
- `probe/giveup-time.mjs`, `probe/giveup-time.txt`: R2 timing.
- `probe/journal-paths.mjs`, `probe/journal-paths.txt`: journal error
paths and J4 types.
- `probe/install-msg.txt`: the rendered install message.
- `probe/mut-Ma-nolog.txt`, `probe/mut-Md-*.txt`: the mutants.