diff --git a/agents/darkwing/work/s4-follow-up-review/out/node-bus.txt b/agents/darkwing/work/s4-follow-up-review/out/node-bus.txt new file mode 100644 index 00000000..63df3746 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/out/node-bus.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/out/node-cli.txt b/agents/darkwing/work/s4-follow-up-review/out/node-cli.txt new file mode 100644 index 00000000..4a3f5c20 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/out/node-cli.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/out/node-discord.txt b/agents/darkwing/work/s4-follow-up-review/out/node-discord.txt new file mode 100644 index 00000000..56dd7fb0 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/out/node-discord.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/out/node-tasks.txt b/agents/darkwing/work/s4-follow-up-review/out/node-tasks.txt new file mode 100644 index 00000000..9c80baad --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/out/node-tasks.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/out/test-discord.txt b/agents/darkwing/work/s4-follow-up-review/out/test-discord.txt new file mode 100644 index 00000000..b013f9ff --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/out/test-discord.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.mjs b/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.mjs new file mode 100644 index 00000000..5271de36 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.mjs @@ -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 }); diff --git a/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.txt b/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.txt new file mode 100644 index 00000000..68fe4fbc --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/giveup-time.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/host-window-nolisten.txt b/agents/darkwing/work/s4-follow-up-review/probe/host-window-nolisten.txt new file mode 100644 index 00000000..44d8b820 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/host-window-nolisten.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/host-window-zombie.txt b/agents/darkwing/work/s4-follow-up-review/probe/host-window-zombie.txt new file mode 100644 index 00000000..daec9be2 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/host-window-zombie.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/host-window.test.mjs b/agents/darkwing/work/s4-follow-up-review/probe/host-window.test.mjs new file mode 100644 index 00000000..09d2cece --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/host-window.test.mjs @@ -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 }))); diff --git a/agents/darkwing/work/s4-follow-up-review/probe/host-window.txt b/agents/darkwing/work/s4-follow-up-review/probe/host-window.txt new file mode 100644 index 00000000..f7b27861 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/host-window.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-1.txt b/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-1.txt new file mode 100644 index 00000000..124da8cf --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-1.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-2.txt b/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-2.txt new file mode 100644 index 00000000..86117774 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/hw-nolisten-2.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/install-msg.txt b/agents/darkwing/work/s4-follow-up-review/probe/install-msg.txt new file mode 100644 index 00000000..7ce57106 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/install-msg.txt @@ -0,0 +1,10 @@ +written: /home/jwoltje/darkwing-scratch/tmp/tmp.u27G38M1Cw/mosaic-bus@.service +next, for one business (one per data root): + mkdir -m 0700 -p /notify/ the notifier refuses a looser directory + write /notify//notify.json, mode 0600: + {"notifyVersion": 1, "binding": ""} or "binding": null for no DMs + systemctl --user enable --now mosaic-bus@ start now and at login + systemctl --user status mosaic-bus@ + journalctl --user -u mosaic-bus@ -f the host's log + systemctl --user stop mosaic-bus@ SIGTERM; restartable +survive logout and reboot only with lingering on: loginctl enable-linger jwoltje diff --git a/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.mjs b/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.mjs new file mode 100644 index 00000000..a5df8d18 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.mjs @@ -0,0 +1,37 @@ +// Row 45 probe: openJournal across directory and file states. Prints the +// error class, exit code and message (paths shortened to ) 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, "")}`; } + 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 }); diff --git a/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.txt b/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.txt new file mode 100644 index 00000000..72f36c7b --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/journal-paths.txt @@ -0,0 +1,19 @@ +new dir opens +missing dir, parent 0500 CliError exit=3 code=-: notify journal directory cannot be created (EACCES): /c1/acme +dir 0500 CliError exit=3 code=-: notify journal directory is not writable (EACCES): /c2 +dir 0300 (no read) opens +dir 0755 CliError exit=3 code=-: notify journal directory must be mode 0700 and owned by this user: /c4 +dir symlink to 0700 CliError exit=3 code=-: notify journal directory must not be a symlink: /c6 +dir dangling symlink Error exit=- code=ENOENT: ENOENT: no such file or directory, mkdir '/c7' +dir path is a file CliError exit=3 code=-: notify journal directory must be mode 0700 and owned by this user: /c8 +parent is a file Error exit=- code=ENOTDIR: ENOTDIR: not a directory, mkdir '/c9/acme' +file 0400 CliError exit=3 code=-: notify journal is not writable (EACCES): /c10/sent.jsonl +file 0644 CliError exit=3 code=-: notify journal must be a regular file, mode 0600, owned by this user: /c11/sent.jsonl +file path is a dir Error exit=- code=EISDIR: EISDIR: illegal operation on a directory, open '/c12/sent.jsonl' +gave-up dm opens +gave-up digest CliError exit=3 code=-: notify journal line 1 is malformed (outcome): /c14/sent.jsonl +refused dm status 403 opens +refused dm status "403" CliError exit=3 code=-: notify journal line 1 is malformed (status): /c16/sent.jsonl +digest day 2026-13-45 opens +at without ms CliError exit=3 code=-: notify journal line 1 is malformed (at): /c18/sent.jsonl +dm decision 7 CliError exit=3 code=-: notify journal line 1 is malformed (decision): /c19/sent.jsonl diff --git a/agents/darkwing/work/s4-follow-up-review/probe/late-send.mjs b/agents/darkwing/work/s4-follow-up-review/probe/late-send.mjs new file mode 100644 index 00000000..ff1b0cc2 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/late-send.mjs @@ -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 > [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 })); +} diff --git a/agents/darkwing/work/s4-follow-up-review/probe/late-send.txt b/agents/darkwing/work/s4-follow-up-review/probe/late-send.txt new file mode 100644 index 00000000..f1b65223 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/late-send.txt @@ -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} diff --git a/agents/darkwing/work/s4-follow-up-review/probe/mut-Ma-nolog.txt b/agents/darkwing/work/s4-follow-up-review/probe/mut-Ma-nolog.txt new file mode 100644 index 00000000..6e73cf8e --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/mut-Ma-nolog.txt @@ -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. (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. (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' + } diff --git a/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-nomkdir.txt b/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-nomkdir.txt new file mode 100644 index 00000000..95d63dda --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-nomkdir.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-test-discord.txt b/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-test-discord.txt new file mode 100644 index 00000000..b013f9ff --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/probe/mut-Md-test-discord.txt @@ -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 diff --git a/agents/darkwing/work/s4-follow-up-review/review-r1.md b/agents/darkwing/work/s4-follow-up-review/review-r1.md new file mode 100644 index 00000000..9d8e30b8 --- /dev/null +++ b/agents/darkwing/work/s4-follow-up-review/review-r1.md @@ -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 ()` 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 /notify/` 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.