From bb33b1c06d70b710a714db0bf97b8030a273c8a7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 14:22:33 -0500 Subject: [PATCH] darkwing: row 81 (#1575) round 2 review, approve 3ce7527f (comment 27821) Co-Authored-By: Claude Opus 5.5 --- .../darkwing/work/queue-81-review/r2/bus.txt | 110 ++++ .../darkwing/work/queue-81-review/r2/cli.txt | 125 +++++ .../work/queue-81-review/r2/control-board.txt | 132 +++++ .../work/queue-81-review/r2/lines.diff | 16 + .../darkwing/work/queue-81-review/r2/r1.lines | 475 +++++++++++++++++ .../darkwing/work/queue-81-review/r2/r2.lines | 485 ++++++++++++++++++ .../work/queue-81-review/r2/webui.txt | 93 ++++ .../work/queue-81-review/review-r2.md | 59 +++ 8 files changed, 1495 insertions(+) create mode 100644 agents/darkwing/work/queue-81-review/r2/bus.txt create mode 100644 agents/darkwing/work/queue-81-review/r2/cli.txt create mode 100644 agents/darkwing/work/queue-81-review/r2/control-board.txt create mode 100644 agents/darkwing/work/queue-81-review/r2/lines.diff create mode 100644 agents/darkwing/work/queue-81-review/r2/r1.lines create mode 100644 agents/darkwing/work/queue-81-review/r2/r2.lines create mode 100644 agents/darkwing/work/queue-81-review/r2/webui.txt create mode 100644 agents/darkwing/work/queue-81-review/review-r2.md diff --git a/agents/darkwing/work/queue-81-review/r2/bus.txt b/agents/darkwing/work/queue-81-review/r2/bus.txt new file mode 100644 index 000000000..850278a7a --- /dev/null +++ b/agents/darkwing/work/queue-81-review/r2/bus.txt @@ -0,0 +1,110 @@ +✔ agents: a held role with nothing on the bus is idle; a role session gets the claim rows unchanged (178.033884ms) +✔ agents: working counts what the holder received and has not read; routed alone is not working (233.207191ms) +✔ agents: input needed is the role's open decisions routed to the human, gated first, then oldest (387.317602ms) +✔ agents: working wins over waiting, and Input needed still shows (178.654016ms) +✔ agents: a new holder doesn't inherit what the last one left unread; decisions stay with the role (207.821646ms) +✔ agents: state reads only its own business, even for a run name both use (182.770003ms) +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (195.454205ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (304.165ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (318.016954ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (181.742854ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (192.111446ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (181.201923ms) +✔ task action subjects and linked decision trail are complete and ordered (195.490133ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (123.738083ms) +✔ business isolation includes inherited object names and cross-business message references (184.523022ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (275.703375ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (137.181007ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (190.519206ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (150.645264ms) +✔ both arbiters require human resolution when their cross-role route is themselves (220.004785ms) +✔ the shipped coder role raises task.reassign as gated to the human; task.scope.change stays cross-role (163.704165ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (3.29428ms) +✔ credentials gives human and reader sessions service, account, role, date and state, and nothing else (140.163044ms) +✔ credentials refuses a role session and any argument (177.669039ms) +✔ credentials reads the status on each request, and a broker without one lists none (266.094726ms) +✔ the broker process answers credentials without a token, path or variable in its output, log or store (238.164468ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (8.344559ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (12.973339ms) +✔ expiry refuses use and env references never become client data (1.976928ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (4.319973ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.546407ms) +✔ roles lists open decisions routed to a role, and never the human inbox (261.394339ms) +✔ recent lists closed decisions of every route, newest close first, with how each closed (327.558955ms) +✔ recent gives the closing event as closed when the decision was seen first (322.108894ms) +✔ recent stops at RECENT rows (824.791654ms) +✔ every row is what trail already gives the same session for that id (267.315656ms) +✔ a role session can't list decisions, so it can't read another role's open decisions (360.164527ms) +✔ decisions is a read: one business, strict arguments, nothing written (365.850124ms) +✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (170.202466ms) +✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (150.426385ms) +✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (180.972542ms) +✔ endLaunch leaves a claim another run took alone (201.960423ms) +✔ refuse records action.refused against the caller with the code only (157.372671ms) +✔ launches off refuses role.launch with launch-revoked until launches on (196.09815ms) +✔ broker process: launch ops authorize role.launch, record refusals and end runs (363.367221ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (7.06371ms) +✔ process reader gets own kernel identity without exposing environment values (13.478775ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (2.328044ms) +✔ real pid 1 remains inspectable when its environment is protected (0.560935ms) +✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (216.819654ms) +✔ missing and foreign-business citations refuse and roll back message and grant (226.209527ms) +✔ cross-role sends still need a matching resolved decision and consume it once (305.682459ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (298.264831ms) +✔ startup token refusal returns safe code without value or partial listening broker (64.448588ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (232.201506ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (219.634617ms) +✔ trusted host registers later launches; socket clients never have a registration verb (197.007994ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (116.92386ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (225.262037ms) +✔ a reply is listed for human and reader sessions, and its delivery rows are unchanged (270.662576ms) +✔ the reply still reaches the human message.receive (mosaic talk) after the Console showed it (230.566505ms) +✔ a message with no reply gives an empty list (180.697958ms) +✔ replies come oldest first and stop at REPLIES (794.797152ms) +✔ an id that names no message in the business refuses message-not-found, and the refusal is recorded (310.714398ms) +✔ strict arguments; a role session gets human-required (367.289033ms) +✔ views passes replies through as one call with the id (0.379927ms) +✔ v3b prototype refusals, views and append-only mutations (1161.994383ms) +✔ gated approval authorizes once, survives store reopen, and fresh approval works (324.954657ms) +✔ another run cannot consume an approval; a failed check leaves it usable (274.079733ms) +✔ two scheduled callers have exactly one grant and one consumed refusal (189.30715ms) +✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (357.138181ms) +✔ class drift gated to cross-role refuses before consumption (232.592421ms) +✔ class drift cross-role to gated refuses before consumption (264.460512ms) +✔ class drift gated to within-role refuses before consumption (211.617875ms) +✔ class drift cross-role to within-role refuses before consumption (247.684167ms) +✔ class drift within-role to gated refuses before consumption (185.257108ms) +✔ class drift within-role to cross-role refuses before consumption (191.263374ms) +✔ message.send consumes approval and prevents a later send or authorize (215.556943ms) +✔ role.revoke consumes approval and prevents a later revoke or authorize (245.431488ms) +✔ creates private WAL store and excludes a second writer until explicit close (153.962537ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (204.539774ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (222.104114ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (195.953277ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (105.850786ms) +✔ async transactions refuse before invoking their function (120.030966ms) +✔ tasks rows carry priority and requirement for every session that reads tasks (360.083688ms) +✔ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (421.812264ms) +✔ an empty business and one with snapshots but no task.created read cleanly (126.349185ms) +✔ recordTask keeps sync reads and a role write apart (201.634263ms) +✔ read_at must be one canonical UTC format, so the projection compares strings safely (133.662015ms) +✔ a bad entry refuses the whole record (117.155294ms) +✔ taskView reads the projection for one business (151.638492ms) +✔ requestTask hands only a holder and a task verb to the handler, and records refusals (270.106117ms) +✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (448.346298ms) +✔ without an adapter the server refuses every task verb (237.541078ms) +✔ the runtime refuses an invalid adapter and closes a valid one (234.795623ms) +✔ the process loads the S3 adapter from plain-data trackers (301.324984ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (184.619884ms) +✔ two wire claims serialize; a lost reply never automatically retries (211.81329ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (143.084829ms) +✔ client preserves UTF-8 when a response divides a multibyte character (12.126074ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (164.111774ms) +ℹ tests 102 +ℹ suites 0 +ℹ pass 102 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3140.146258 diff --git a/agents/darkwing/work/queue-81-review/r2/cli.txt b/agents/darkwing/work/queue-81-review/r2/cli.txt new file mode 100644 index 000000000..83863951a --- /dev/null +++ b/agents/darkwing/work/queue-81-review/r2/cli.txt @@ -0,0 +1,125 @@ +✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1673.410949ms) +✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (82.440573ms) +✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (196.160942ms) +✔ NO_COLOR: no escape codes, except reverse video on the selected row (286.462458ms) +✔ 1-9 shows the decide flow's own lines and question; only y resolves (167.836473ms) +✔ the app shows the same decide text as mosaic decide (97.685135ms) +✔ any key but y or Y at the question answers No; nothing is sent (1182.186682ms) +✔ below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3) (461.03673ms) +✔ a throw while answering the question goes to crash, sends nothing and leaves no job pending (115.577359ms) +✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (846.047328ms) +✔ quitting at the question answers No and sends nothing, armed or not (230.178383ms) +✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (136.542143ms) +✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (112.42533ms) +✔ a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3) (908.285447ms) +✔ at every size the decide box shows each line y depends on, or says it is hidden (11834.654425ms) +✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (260.84321ms) +✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (215.873188ms) +✔ keys: views, moving, trail of a selection, the command line, help and quit (500.325643ms) +✔ one option: the status line says Press 1, not 1-1 (134.119457ms) +✔ no business: the app says so and :business picks one (85.34718ms) +✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (1.549331ms) +✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (181.832308ms) +✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.714608ms) +✔ decide with --note still asks; a No sends nothing (85.137918ms) +✔ the app's business comes from a live bus host only (1.107905ms) +✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included (7.871332ms) +✔ draw is pure: same state, same frame (0.940144ms) +✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (118.883379ms) +✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (124.388544ms) +✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (116.404965ms) +✔ decide prints a declining choice as declining (110.381547ms) +✔ an unknown outcome is reported once and never resent (95.061928ms) +✔ a decision closed before the answer arrives exits 2 and points at its trail (103.931272ms) +✔ a prefix that matches two open decisions exits 2 and resolves neither (97.965957ms) +✔ without --business a command uses the live host's business, and a stale host.json is not a host (89.12093ms) +✔ every human command refuses inside an agent run before it touches the bus (95.813626ms) +✔ usage errors exit 4; no business and no host is a usage error (92.87798ms) +✔ agents and tasks print through the broker (81.54836ms) +✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.352666ms) +✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (73.690253ms) +✔ trackers come from the tracker.* variables of the one project that names a tracker project (92.406068ms) +✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (43.641582ms) +✔ two projects that each name a tracker project refuse, since the boot shape holds one (49.46238ms) +✔ a business without tracker.baseUrl gets no trackers entry (45.89956ms) +✔ an unknown business and a broken system config refuse with exit 3 (111.307457ms) +✔ empty views say so (1.476436ms) +✔ the trail keeps the broker's order and names a decision's task without its rows (1.8302ms) +✔ tasks print the tracker fields the snapshot carries (0.421406ms) +✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1036.922051ms) +✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (284.848868ms) +✔ a second host for the same data root refuses with exit 3 while the first runs (218.319085ms) +✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1203.91489ms) +✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (406.172536ms) +✔ a broker reply with no request waiting breaks the channel and the host exits 1 (251.846781ms) +✔ a notifier that refuses stops the broker and the host refuses with exit 3 (264.617581ms) +✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (226.054405ms) +✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (168.913338ms) +✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (216.583026ms) +✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (168.431042ms) +✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (262.610597ms) +✔ watchChildren reports a child that died before it was called, and one that dies later (31.048136ms) +✔ bus stop refuses to signal a live pid that is not a bus host (203.811843ms) +✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (286.158484ms) +✔ bus start refuses with exit 3 without a notifier config (127.821958ms) +✔ bus start runs until bus stop; status reports it while it runs (814.138214ms) +✔ bus-service.sh renders the unit and installs it into a given directory (59.324083ms) +✔ bus-service.sh pins the claude binary, refuses one the unit can't carry, and renders without one (76.72669ms) +✔ bus-service.sh refuses a directory pin and keeps a symlink default unresolved (21.974109ms) +✔ bus-service.sh refuses a default claude without the execute bit, or in a PATH dir with a space (12.961002ms) +✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1339.102936ms) +✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (743.682747ms) +✔ a runner that stops at once ends its launch with the runner's reason (259.151979ms) +✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (847.132648ms) +✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3872.561901ms) +✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (181.12886ms) +✔ an over-long launch request is refused at once, not at the 10 s idle timeout (179.839447ms) +✔ a launch client that never closes its side doesn't hold the host's close (174.340008ms) +✔ a runner that ignores SIGTERM is killed when the host closes (1296.82585ms) +✔ claudeVersion asks MOSAIC_CLAUDE_BIN when set, else claude on PATH (6.622259ms) +✔ zoned uses the IANA zone across DST (27.29447ms) +✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (127.300238ms) +✔ two blocking decisions get two DMs with different nonces (125.569035ms) +✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.274918ms) +✔ a failed DM is journaled, backs off, and is retried until it lands (109.506177ms) +✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (103.838398ms) +✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (110.85142ms) +✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (101.0714ms) +✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (180.107464ms) +✔ a restart after the second refusal does not send before that refusal's 30 min are up (136.551252ms) +✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (115.17664ms) +✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (97.775802ms) +✔ an inbox read failure is logged and the next poll retries (0.859233ms) +✔ no Discord id reaches the journal or the log (101.42341ms) +✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (25.964237ms) +✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (34.324809ms) +✔ the journal: a whole file that is one torn line truncates to empty (19.565659ms) +✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.961214ms) +✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (1.168092ms) +✔ the journal: a line with a wrong type refuses with exit 3 and names the field (12.959481ms) +✔ the journal: a symlinked directory refuses and says it is a link (0.864183ms) +✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (1.347884ms) +✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.795563ms) +✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.940249ms) +✔ digest content stays within Discord's 2000 characters (0.497644ms) +✔ runLoop never overlaps ticks and stops after the one in flight (111.669611ms) +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 (480.312515ms) +✔ the transport writes {business, verb, args} to the child and reads its JSON (56.059311ms) +✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2210.523424ms) +✔ busExit and refuseInsideAgent (0.501162ms) +✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (210.496829ms) +✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1130.454413ms) +✔ a late reply the Console has listed still reaches the next talk (2147.440955ms) +✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (104.67927ms) +✔ launches off and on go to the broker and change the business's launch state (117.073492ms) +✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (104.944973ms) +ℹ tests 115 +ℹ suites 0 +ℹ pass 115 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 19972.560678 diff --git a/agents/darkwing/work/queue-81-review/r2/control-board.txt b/agents/darkwing/work/queue-81-review/r2/control-board.txt new file mode 100644 index 000000000..d1bff6747 --- /dev/null +++ b/agents/darkwing/work/queue-81-review/r2/control-board.txt @@ -0,0 +1,132 @@ +✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (6.407794ms) +✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.475021ms) +✔ completed smoke replies and ordinary questions are idle, not human blockers (1.235188ms) +✔ only an explicit first-line input request makes a finished reply waiting (0.242096ms) +✔ tool activity, user text, errors and unfinished turns override attention text (0.265787ms) +✔ STOP access failure is unknown, not absence, under a non-root identity (41.445822ms) +✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (3.064733ms) +✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (1.95698ms) +✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (8.001652ms) +✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (0.683652ms) +✔ server rescans connector discovery and refuses HTTP reply without transport (38.488871ms) +✔ connector session links and linked directories are not read (1.122246ms) +✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (3.669766ms) +✔ CLI print uses the relaunch notice instead of old current preview (78.45425ms) +✔ connector owner and fixed task never inherit a native relaunch notice (2.15473ms) +✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (1.241717ms) +✔ loadConfig: missing file throws ConfigError (2.721808ms) +✔ loadConfig: invalid JSON throws ConfigError (0.674841ms) +✔ loadConfig: missing dataRoot throws ConfigError (0.4257ms) +✔ loadConfig: relative dataRoot throws ConfigError (0.397879ms) +✔ loadConfig: valid config returns dataRoot (1.257358ms) +✔ findNewestSession: picks the newest by mtime among two files (0.706449ms) +✔ findNewestSession: finds files in nested subdirectories (0.494768ms) +✔ findNewestSession: returns null for a missing dir (0.171212ms) +✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (0.885932ms) +✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (1.855392ms) +✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (0.857576ms) +✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (1.877425ms) +✔ deriveState: full state table (0.217631ms) +✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.624509ms) +✔ rule: newest entry is a tool result with no assistant text after it is working (0.35346ms) +✔ rule: a finished ordinary turn is idle, even if it says your move (0.341674ms) +✔ task: the first user message of the session, from text blocks (0.32304ms) +✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.341081ms) +✔ task: no user message in the log means null (shown as unknown), never a guess (0.329434ms) +✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.459656ms) +✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (0.964974ms) +✔ scan: the written record carries task, workspace and activeProject (0.942075ms) +✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (1.031192ms) +✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.524077ms) +✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (1.28092ms) +✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.506407ms) +✔ loadRegistrations: a missing seatsDir gives empty lists (0.156282ms) +✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (1.478558ms) +✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (0.538708ms) +✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (2.057487ms) +✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.626081ms) +✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.842678ms) +✔ scanAgent: ageSeconds is computed from the injected now (0.421442ms) +✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.284775ms) +✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.45348ms) +✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.388559ms) +✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.18129ms) +✔ scan: relative boardDir throws ConfigError (0.117305ms) +✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (70.170848ms) +✔ CLI: missing config exits 2 with a refused: message (59.020623ms) +✔ CLI: unknown command exits 2 (60.137253ms) +✔ CLI: unknown --liveness value exits 2 (55.116675ms) +✔ panesRunPi: true when any trimmed line equals 'pi' (0.197744ms) +✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.077857ms) +✔ tmuxIsAlive: a pane running pi is alive (0.177612ms) +✔ tmuxIsAlive: session exists but pi has exited is not alive (0.072608ms) +✔ tmuxIsAlive: no such tmux session is not alive (0.048792ms) +✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.058335ms) +✔ tmuxIsAlive: passes -L only when a socket is given (0.091562ms) +✔ parsePanes: one pane per line, command and optional tab-separated path (0.081745ms) +✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.069992ms) +✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.089474ms) +✔ loadSeen: missing file returns {} (0.135065ms) +✔ loadSeen: invalid JSON throws ConfigError (0.234086ms) +✔ loadSeen: a JSON array throws ConfigError (0.197912ms) +✔ loadSeen: a non-string value throws ConfigError (0.179344ms) +✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.320619ms) +✔ markSeen: seen false deletes the key (0.300051ms) +✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.192028ms) +✔ markSeen: project containing '/' throws ConfigError (0.131783ms) +✔ markSeen: non-boolean seen throws ConfigError (0.106877ms) +✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.286826ms) +✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.256187ms) +✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.299924ms) +✔ scanAgent: an error-state session with a matching mark is seen (0.207824ms) +✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.515488ms) +✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.179077ms) +✔ taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered (0.613373ms) +✔ taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present (0.570721ms) +✔ taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value (0.999052ms) +✔ isLoopbackHost: recognizes loopback hosts (1.576757ms) +✔ isLoopbackHost: rejects non-loopback hosts (5.300296ms) +✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (3.901256ms) +✔ startServer: serves page, healthz, and a rescanning /api/board (38.579531ms) +✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (6.66361ms) +✔ startServer: /api/board returns 500 JSON with an error field when scan throws (2.509946ms) +✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (62.139803ms) +✔ CLI: serve rejects a non-numeric --port with exit 2 (58.373066ms) +✔ CLI: scan still works after the async cli refactor (60.578236ms) +✔ CLI: live serve prints its URL and answers /healthz (69.046792ms) +✔ page.html: esc() escapes every HTML-significant character (0.634291ms) +✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (8.600739ms) +✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (2.141695ms) +✔ POST /api/seen with invalid JSON returns 400 (2.881514ms) +✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (2.114614ms) +✔ POST /api/seen with a missing agent returns 400 (1.481983ms) +✔ POST /api/board returns 405; PUT /api/seen returns 405 (1.737046ms) +✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (54.350356ms) +✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.381703ms) +✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.31484ms) +✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.208566ms) +✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.162511ms) +✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.371402ms) +✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (0.641594ms) +✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (31.313776ms) +✔ POST /api/reply: a registration with a tmux socket adds -L (29.589902ms) +✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (33.115387ms) +✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (22.365761ms) +✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (5.85574ms) +✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (0.246999ms) +✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.694403ms) +✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (3.346138ms) +✔ page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it (0.470722ms) +✔ Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers (13.295763ms) +✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (48.612512ms) +✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (7.437765ms) +✔ every refusal code the reader can raise has an HTTP status (1.284626ms) +✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (68.196301ms) +ℹ tests 124 +ℹ suites 0 +ℹ pass 124 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 783.604369 diff --git a/agents/darkwing/work/queue-81-review/r2/lines.diff b/agents/darkwing/work/queue-81-review/r2/lines.diff new file mode 100644 index 000000000..2e048b6c2 --- /dev/null +++ b/agents/darkwing/work/queue-81-review/r2/lines.diff @@ -0,0 +1,16 @@ +199a200,203 +> - REQUEST. No reply in time exits 1; the reply still lands in the inbox. +> - `--wait 0` only sends. +> + REQUEST. No reply in time exits 1; the reply may still come, and the next +> + `mosaic talk` prints it. `--wait 0` only sends. +252a257,259 +> -// `message.send` (lead decision 95), built by the CLI only with --send. The +> -// bus does the proof; a server started inside an agent run gets +> -// `human-required` from the broker. +254c261,264 +< +// reachable as reads: no read writes, not even seen or delivered (Q4). The one write is row 76's human +--- +> +// reachable as reads: no read writes, not even seen or delivered (Q4). The +> +// one write is row 76's human `message.send` (lead decision 95), built by +> +// the CLI only with --send. The bus does the proof; a server started inside +> +// an agent run gets `human-required` from the broker. diff --git a/agents/darkwing/work/queue-81-review/r2/r1.lines b/agents/darkwing/work/queue-81-review/r2/r1.lines new file mode 100644 index 000000000..940e894b7 --- /dev/null +++ b/agents/darkwing/work/queue-81-review/r2/r1.lines @@ -0,0 +1,475 @@ ++| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | +-capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials for one ++capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials/replies for one ++`replies` (row 81, #1575) is also for human and reader sessions only; a role ++session gets `human-required`. It takes `{id}`, nothing else, and returns ++the business's messages whose `in_reply_to` is that id, in broker write ++order, at most `REPLIES` (50). Each row is the `messages` row `trail` gives ++the same session for the reply's id. An id that names no message in the ++session's business refuses `message-not-found`, as `message.send` does for ++`in_reply_to`; `trail` returns an empty list for such an id instead. The ++read writes no `deliveries` row, so a reply the WebUI's Sent receipt lists ++still reaches the human's next `message.receive` (`mosaic talk`). It is a ++separate verb rather than a `trail` field so `trail`'s shape and its ++empty-list answer for unknown subjects stay as they are. ++ ++await read.replies(messageId); ++// How many replies `replies` returns, oldest first. ++export const REPLIES = 50; +- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials'].includes(request.verb)) ++ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies'].includes(request.verb)) ++ // Row 81 (#1575): the replies to a message, for the Console's web-send receipt. Human and ++ // reader sessions only, like decisions. It writes no delivery or read row, so a reply it ++ // lists still reaches the next `mosaic talk`. An id naming no message in the business ++ // refuses message-not-found, as message.send does for in_reply_to; trail would return []. ++ case 'replies': { ++ if (!s.human && !s.reader) fail('human-required'); ++ keys(a, ['id'], ['id']); ++ identifier(a.id); ++ if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business)) ++ fail('message-not-found'); ++ return this.#store.all( ++ `SELECT * FROM messages WHERE business=? AND in_reply_to=? ORDER BY seq LIMIT ${REPLIES}`, ++ s.business, ++ a.id, ++ ); ++ } ++ replies: (id) => client.call('replies', { id }), ++// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages ++// answering one message, oldest first, at most REPLIES, for human and reader sessions. It ++// writes no delivery or read row, so the human's next message.receive (mosaic talk) still ++// gets each reply. ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { Store } from '../src/store.mjs'; ++import { Broker, REPLIES } from '../src/broker.mjs'; ++import { views } from '../src/views.mjs'; ++ ++const roles = { ++ pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ coder: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++}; ++const businesses = { ++ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles }, ++ other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles }, ++}; ++function setup(t) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-replies-')); ++ const store = new Store(root); ++ const b = new Broker({ store, businesses }); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const agent = (role, business = 'demo') => { ++ const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` }); ++ b.request(cap, { verb: 'role.claim' }); ++ return cap; ++ }; ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const reader = b.bindReader({ business: 'demo' }); ++ const coder = agent('coder'); ++ const pm = agent('pm'); ++ // The human's web send, as row 76's Console makes it. ++ const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' }); ++ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq'); ++ const count = () => ++ ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map( ++ (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n, ++ ); ++ return { b, store, agent, human, reader, coder, pm, sent, deliveries, count }; ++} ++const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); ++const reply = (b, cap, to, body, extra = {}) => ++ call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra }); ++ ++test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => { ++ const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t); ++ call(b, coder, 'message.receive'); ++ const answer = reply(b, coder, sent.id, 'Done: 3 files.'); ++ // Not replies to the send: a message to the human with no in_reply_to, and a reply to another message. ++ call(b, pm, 'message.send', { to: 'human', body: 'fyi' }); ++ const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' }); ++ reply(b, pm, other.id, 'Another answer'); ++ ++ const before = deliveries(); ++ const counts = count(); ++ for (const cap of [human, reader]) { ++ const rows = call(b, cap, 'replies', { id: sent.id }); ++ assert.deepEqual( ++ rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]), ++ [[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']], ++ ); ++ // The whole row is the messages row trail gives the same session for that id. ++ const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages'); ++ assert.equal(table, 'messages'); ++ assert.deepEqual({ ...rows[0] }, m); ++ } ++ assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either'); ++ assert.deepEqual(count(), counts, 'a successful read writes nothing'); ++ assert.deepEqual( ++ store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op), ++ ['routed'], ++ ); ++}); ++ ++test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => { ++ const { b, human, reader, coder, sent } = setup(t); ++ const answer = reply(b, coder, sent.id, 'Done.'); ++ assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); ++ assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); ++ const got = call(b, human, 'message.receive'); ++ assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]); ++ assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true }); ++ assert.deepEqual(call(b, human, 'message.receive'), []); ++ // Once received and read it is still listed: the read is history, not an inbox. ++ assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]); ++}); ++ ++test('a message with no reply gives an empty list', (t) => { ++ const { b, human, reader, sent } = setup(t); ++ for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []); ++}); ++ ++test('replies come oldest first and stop at REPLIES', (t) => { ++ const { b, reader, coder, pm, sent } = setup(t); ++ assert.equal(REPLIES, 50); ++ const ids = []; ++ for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id); ++ const rows = call(b, reader, 'replies', { id: sent.id }); ++ assert.equal(rows.length, REPLIES); ++ assert.deepEqual( ++ rows.map((m) => m.id), ++ ids.slice(0, REPLIES), ++ ); ++ assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [ ++ ['coder', 'reply 0'], ++ ['pm', 'reply 1'], ++ ['coder', 'reply 2'], ++ ]); ++}); ++ ++test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => { ++ const { b, store, agent, human, reader, pm, sent } = setup(t); ++ const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' }); ++ const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n; ++ for (const cap of [human, reader]) ++ for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) { ++ const before = refusals(); ++ assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id); ++ assert.equal(refusals(), before + 1, 'the refusal is recorded'); ++ } ++ // A decision id is not a message id either. ++ const d = call(b, pm, 'decision.raise', { ++ action: 'deploy', ++ target: 'release-1', ++ question: 'Deploy?', ++ options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], ++ recommendation: 'yes', ++ blocking: false, ++ }); ++ assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' }); ++ // The other business's reader finds its own message, not demo's. ++ const otherReader = b.bindReader({ business: 'other' }); ++ assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []); ++ assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' }); ++}); ++ ++test('strict arguments; a role session gets human-required', (t) => { ++ const { b, human, reader, coder, pm, sent } = setup(t); ++ for (const cap of [human, reader]) ++ for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '' }, { id: sent.id, limit: 5 }, { subject: sent.id }]) ++ assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args)); ++ // A role can't read the human's replies, even to a message it sent itself. ++ const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' }); ++ for (const [cap, id] of [[coder, sent.id], [pm, own.id]]) ++ assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' }); ++ // The reader's other limits stand. ++ assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' }); ++}); ++ ++test('views passes replies through as one call with the id', async () => { ++ const calls = []; ++ const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) }); ++ assert.deepEqual(await read.replies('m-1'), []); ++ assert.deepEqual(calls, [['replies', { id: 'm-1' }]]); ++}); +- if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk or mosaic inbox shows it`, 1); ++ if (Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1); +- assert.match(slow.error, /no reply from coder within 1 s; .*mosaic inbox/); ++ // Row 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages. ++ assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it"); ++}); ++ ++test("a late reply the Console has listed still reaches the next talk", async (t) => { ++ const s = setup(t); ++ assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1); ++ const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1"); ++ assert.equal(call(s.b, s.coder, "message.receive")[0].id, id); ++ const answer = call(s.b, s.coder, "message.send", { to: "human", body: "late answer", class: "RESULT", in_reply_to: id }); ++ // What the Console's send receipt reads, through its reader session. ++ assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]); ++ const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]); ++ assert.equal(next.code, 1, "no answer to the second one"); ++ assert.match(next.io.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`)); ++ assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]); +- the message and its deliveries. A reply is not in it. The text is cleared +- unless it was edited during the send. ++ the message and its deliveries. A reply is not in the trail; it shows ++ under the receipt (below). The text is cleared unless it was edited ++ during the send. +-The role's reply comes back as a message to the human. The Console doesn't +-show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a +-terminal prints it, within talk's limits: ++The role's reply comes back as a message to the human with `in_reply_to` ++set to the sent message's id. Since row 81 (#1575) the Sent receipt lists ++those replies, oldest first, each with its role, time, class, body and a ++trail link. It reads them with the bus `replies` read on every render, so a ++reply shows on the next ten-second refresh or Read again. "No reply yet" ++means the read returned none; "could not be read" means it failed, and the ++next refresh tries again. The bus returns at most 50; at 50 the receipt says ++only the first 50 show. The list belongs to the last Sent receipt: a new ++send starts its own, and reloading the page drops it. ++ ++The Console only shows replies. It never receives a message or marks one ++delivered or read: `replies` writes no `deliveries` row. So Jason's next ++`mosaic talk` in a terminal still prints each reply, within talk's limits: +-`/api/bus/trail?subject=`, `/api/bus/decisions?list=roles|recent` and +-`/api/bus/credentials` are the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match +-the broker's identifier rule, and the list must be `roles` or `recent`, else +-400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and ++`/api/bus/trail?subject=`, `/api/bus/decisions?list=roles|recent`, ++`/api/bus/credentials` and `/api/bus/replies?id=` are the bus ++reads (`src/bus.mjs`). Any other method is 405. The subject and the id must ++match the broker's identifier rule, and the list must be `roles` or ++`recent`, else 400. `replies` (row 81, #1575) is the messages whose ++`in_reply_to` is that id, for the Sent receipt; the bus refuses an id that ++names no message (`message-not-found`, 400). `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and +-// `decisions` and row 60's `credentials` reads are reachable as reads: no +-// read writes, not even seen (Q4). The one write is row 76's human ++// `decisions`, row 60's `credentials` and row 81's `replies` reads are ++// reachable as reads: no read writes, not even seen or delivered (Q4). The one write is row 76's human ++ // `replies` takes a message id as `id`; trail takes `subject`. +- res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' }); ++ res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' }); +- let canSend = false, sendRoles = null, sending = false, receipt = null; ++ // Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each ++ // render. That read marks nothing, so every reply still reaches the next `mosaic talk`. ++ let canSend = false, sendRoles = null, sending = false, receipt = null, replies = []; ++ const REPLIES = 50; // the broker's cap ++ function repliesHtml() { ++ if (replies === null) return '

The replies to it could not be read. Read again to retry; mosaic talk in a terminal prints them.

'; ++ if (!replies.length) return '

No reply yet. One shows here when the role answers, as the page refreshes.

'; ++ return `

Replies ${replies.length}

    ${replies.map(m => `
  1. ${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · trail

    ${txt(m.body)}

  2. `).join('')}
${replies.length >= REPLIES ? `

The first ${REPLIES} replies show here.

` : ''}`; ++ } +- ? `

Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.

` ++ ? `

Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.

${repliesHtml()}` +-

Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next mosaic talk in a terminal prints it, after sending its own message.

++

Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. Replies to it show under the receipt. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one.

++ if (receipt?.kind === 'sent') { ++ const r = receipt; ++ let rows = null; ++ try { rows = await get('replies', r.id); } catch {} ++ // A send that finished during this read has its own receipt; leave it alone. ++ if (receipt === r) replies = Array.isArray(rows) ? rows : null; ++ } +- receipt = { kind: 'sent', to, id: body.id, at: body.at }; ++ receipt = { kind: 'sent', to, id: body.id, at: body.at }; replies = []; +-// (#1549) adds `credentials`: what the bus host loaded, metadata only. +-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials']; ++// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81 ++// (#1575) adds `replies`: the messages answering one message, for the send receipt. ++const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies']; ++ if (verb === 'replies') { ++ arg = new URLSearchParams(search).get('id'); ++ if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' }); ++ } ++// Row 81 (#1575): the replies to one message, for the Console's send receipt. The bus refuses an ++// id that names no message; a malformed one never reaches it. ++test('replies route serves the replies to one message from the reader view', async t => { ++ const store = new Store(tmp(t, 'webui-bus-replies-')); ++ t.after(() => store.close()); ++ const b = new Broker({ store, businesses }); ++ const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' }); ++ b.request(coder, { verb: 'role.claim' }); ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const sent = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look.' } }); ++ const quiet = b.request(human, { verb: 'message.send', args: { to: 'coder', body: 'No answer to this one.' } }); ++ const answer = b.request(coder, { verb: 'message.send', args: { to: 'human', body: `Done. ${HOSTILE}`, class: 'RESULT', in_reply_to: sent.id } }); ++ const reader = b.bindReader({ business: 'demo' }); ++ const calls = []; ++ // A broker refusal comes back from the transport as a BusReadError carrying its code. ++ const call = async (verb, args = {}) => { ++ calls.push([verb, args]); ++ try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); } ++ }; ++ const bus = views({ call }); ++ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus }); ++ t.after(() => close(web)); ++ const base = `http://127.0.0.1:${web.address().port}`; ++ const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; }; ++ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq'); ++ const before = deliveries(); ++ ++ const r = await read('/api/bus/replies?id=' + sent.id); ++ assert.equal(r.status, 200); assert.equal(r.cache, 'no-store'); ++ assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); ++ assert.deepEqual(r.body.rows.map(m => [m.id, m.from_role, m.class, m.in_reply_to, m.body]), [[answer.id, 'coder', 'RESULT', sent.id, `Done. ${HOSTILE}`]]); ++ assert.deepEqual((await read('/api/bus/replies?id=' + quiet.id)).body.rows, []); ++ const unknown = await read('/api/bus/replies?id=00000000-0000-4000-8000-000000000000'); ++ assert.deepEqual([unknown.status, unknown.body.error], [400, 'message-not-found']); ++ assert.deepEqual(deliveries(), before, 'no delivered or read row'); ++ assert.deepEqual(calls, [['replies', { id: sent.id }], ['replies', { id: quiet.id }], ['replies', { id: '00000000-0000-4000-8000-000000000000' }]]); ++ ++ // A missing or malformed id never reaches the bus; trail's `subject` is not this read's argument. ++ const n = calls.length; ++ for (const q of ['', '?id=', '?id=' + 'a'.repeat(161), '?id=-x', '?id=a%20b', '?id=%3Cb%3E', '?subject=' + sent.id]) ++ assert.deepEqual([q, (await read('/api/bus/replies' + q)).body.error], [q, 'invalid-request']); ++ assert.equal(calls.length, n); ++}); ++ +- const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'].map(v => [v, async () => { calls.push(v); return []; }])); ++ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }])); ++ assert.equal((await fetch(base + '/api/bus/replies?id=m-1', { method })).status, 405, method); +- for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request']]) { ++ for (const [query, status, error] of [['decisions?list=bad', 400, 'invalid-request'], ['decisions?list=roles', 503, 'not-configured'], ['trail?subject=%3C', 400, 'invalid-request'], ['replies?id=%3C', 400, 'invalid-request'], ['replies?id=m-1', 503, 'not-configured']]) { ++ assert.deepEqual((await pinned.replies('m-1')).request, { business: 'pinned', verb: 'replies', args: { id: 'm-1' } }); ++import { BusReadError } from '../src/bus.mjs'; ++const HOSTILE = ' \u001b[31mred \u202eevil'; +- assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./); ++ assert.match(await text('#send-section'), /Replies to it show under the receipt\. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one\./); ++ ++// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each ++// render. The read writes no delivery or read row, so the human's next message.receive ++// (mosaic talk) still gets every reply the page showed. ++test('a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read', { timeout: 120000 }, async () => { ++ const root = mkdtempSync(join(tmpdir(), 'webui-replies-browser-')); ++ const store = new Store(root), b0 = new Broker({ store, businesses }); ++ const caps = {}; ++ for (const role of ['pm', 'coder']) b0.request(caps[role] = b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' }); ++ const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const sent = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look at row 81.' } }); ++ const other = b0.request(human, { verb: 'message.send', args: { to: 'pm', body: 'Another question' } }); ++ const reader = b0.bindReader({ business: 'demo' }); ++ // A broker refusal comes back from the transport as a BusReadError carrying its code. While ++ // `hold` is set, a replies read waits for it, counted in `held`. ++ let hold = null, held = 0; ++ const bus = views({ ++ call: async (verb, args = {}) => { ++ if (verb === 'replies' && hold) { held++; await hold.promise; } ++ try { return structuredClone(b0.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); } ++ }, ++ }); ++ const f = await fixture(), b = await browser(); ++ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; ++ const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); ++ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); ++ const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); ++ const send = async (id, body) => { ++ await set('#send-body', body); ++ await b.evaluate(`window.__answers.push(${JSON.stringify({ status: 200, body: { id, at: new Date().toISOString() } })}), true`); ++ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); ++ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); ++ }; ++ const refresh = async () => { ++ await b.evaluate('(()=>{document.querySelector("#send-section").dataset.before="1";document.querySelector("#refresh").click();return true})()'); ++ await wait('!document.querySelector("#send-section")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")'); ++ }; ++ const items = () => b.evaluate('[...document.querySelectorAll("#send-section .s1-replies li")].map(li => [...li.querySelectorAll("p")].map(p => p.textContent).concat(li.querySelector("a").getAttribute("href")))'); ++ const reply = (role, to, body) => b0.request(caps[role], { verb: 'message.send', args: { to: 'human', body, class: 'RESULT', ...(to ? { in_reply_to: to } : {}) } }); ++ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq').map(d => ({ ...d })); ++ const NONE = 'No reply yet. One shows here when the role answers, as the page refreshes.'; ++ try { ++ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); ++ await b.viewport(1280); ++ await b.navigate(`${base}#/inbox`); ++ await wait('document.querySelector("#send-form")'); ++ await set('#send-to', 'coder'); ++ ++ // Straight after the send there is no reply. ++ await send(sent.id, 'Please look at row 81.'); ++ assert.match(await text('#send-section .receipt'), new RegExp(`^Sent to coder .*, message ${sent.id.slice(0, 8)}\\. Its trail\\.$`)); ++ assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.'))); ++ assert.deepEqual(await items(), []); ++ ++ // Replies to the sent message show on Read again, oldest first, as text. A reply to another ++ // message and a message with no in_reply_to don't. ++ const first = reply('coder', sent.id, `Done: 3 files. ${HOSTILE}`); ++ reply('pm', other.id, 'Another answer'); ++ reply('pm', null, 'fyi'); ++ const second = reply('pm', sent.id, 'Second look.'); ++ let before = deliveries(); ++ await refresh(); ++ assert.deepEqual(deliveries(), before, 'no delivered or read row'); ++ assert.equal(await text('#send-section h3'), 'Replies 2'); ++ const two = await items(); ++ assert.deepEqual(two.map(([meta, , href]) => [meta.split(' ')[0], / · RESULT · trail$/.test(meta), href]), [['coder', true, `#/trail/message/${first.id}`], ['pm', true, `#/trail/message/${second.id}`]]); ++ assert.match(two[0][1], /^Done: 3 files\. \u001b[31mred \u202eevil'; +- assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./); ++ assert.match(await text('#send-section'), /Replies to it show under the receipt\. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one\./); ++ ++// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each ++// render. The read writes no delivery or read row, so the human's next message.receive ++// (mosaic talk) still gets every reply the page showed. ++test('a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read', { timeout: 120000 }, async () => { ++ const root = mkdtempSync(join(tmpdir(), 'webui-replies-browser-')); ++ const store = new Store(root), b0 = new Broker({ store, businesses }); ++ const caps = {}; ++ for (const role of ['pm', 'coder']) b0.request(caps[role] = b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' }); ++ const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const sent = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look at row 81.' } }); ++ const other = b0.request(human, { verb: 'message.send', args: { to: 'pm', body: 'Another question' } }); ++ const reader = b0.bindReader({ business: 'demo' }); ++ // A broker refusal comes back from the transport as a BusReadError carrying its code. While ++ // `hold` is set, a replies read waits for it, counted in `held`. ++ let hold = null, held = 0; ++ const bus = views({ ++ call: async (verb, args = {}) => { ++ if (verb === 'replies' && hold) { held++; await hold.promise; } ++ try { return structuredClone(b0.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); } ++ }, ++ }); ++ const f = await fixture(), b = await browser(); ++ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; ++ const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); ++ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); ++ const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); ++ const send = async (id, body) => { ++ await set('#send-body', body); ++ await b.evaluate(`window.__answers.push(${JSON.stringify({ status: 200, body: { id, at: new Date().toISOString() } })}), true`); ++ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); ++ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); ++ }; ++ const refresh = async () => { ++ await b.evaluate('(()=>{document.querySelector("#send-section").dataset.before="1";document.querySelector("#refresh").click();return true})()'); ++ await wait('!document.querySelector("#send-section")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")'); ++ }; ++ const items = () => b.evaluate('[...document.querySelectorAll("#send-section .s1-replies li")].map(li => [...li.querySelectorAll("p")].map(p => p.textContent).concat(li.querySelector("a").getAttribute("href")))'); ++ const reply = (role, to, body) => b0.request(caps[role], { verb: 'message.send', args: { to: 'human', body, class: 'RESULT', ...(to ? { in_reply_to: to } : {}) } }); ++ const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq').map(d => ({ ...d })); ++ const NONE = 'No reply yet. One shows here when the role answers, as the page refreshes.'; ++ try { ++ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); ++ await b.viewport(1280); ++ await b.navigate(`${base}#/inbox`); ++ await wait('document.querySelector("#send-form")'); ++ await set('#send-to', 'coder'); ++ ++ // Straight after the send there is no reply. ++ await send(sent.id, 'Please look at row 81.'); ++ assert.match(await text('#send-section .receipt'), new RegExp(`^Sent to coder .*, message ${sent.id.slice(0, 8)}\\. Its trail\\.$`)); ++ assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.'))); ++ assert.deepEqual(await items(), []); ++ ++ // Replies to the sent message show on Read again, oldest first, as text. A reply to another ++ // message and a message with no in_reply_to don't. ++ const first = reply('coder', sent.id, `Done: 3 files. ${HOSTILE}`); ++ reply('pm', other.id, 'Another answer'); ++ reply('pm', null, 'fyi'); ++ const second = reply('pm', sent.id, 'Second look.'); ++ let before = deliveries(); ++ await refresh(); ++ assert.deepEqual(deliveries(), before, 'no delivered or read row'); ++ assert.equal(await text('#send-section h3'), 'Replies 2'); ++ const two = await items(); ++ assert.deepEqual(two.map(([meta, , href]) => [meta.split(' ')[0], / · RESULT · trail$/.test(meta), href]), [['coder', true, `#/trail/message/${first.id}`], ['pm', true, `#/trail/message/${second.id}`]]); ++ assert.match(two[0][1], /^Done: 3 files\.