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:
@@ -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.
|
||||
Reference in New Issue
Block a user