diff --git a/agents/darkwing/work/queue-55/BUILD.md b/agents/darkwing/work/queue-55/BUILD.md index 5f99d9120..170e9ccac 100644 --- a/agents/darkwing/work/queue-55/BUILD.md +++ b/agents/darkwing/work/queue-55/BUILD.md @@ -1,4 +1,4 @@ -# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1 and 2 +# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1, 2 and 3 Author: Darkwing. Reviewers: Dewey and Filbert. Brief: `docs/plans/2026-10-10_design-implementation.md`, section "Full-screen @@ -375,3 +375,172 @@ sequence. one test. - Candidate manifest sha256: `3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7`. + +## Round 3 + +Round 2 drew changes requested from both reviewers on candidate +`3f2d8f8e`. Filbert's comment 27269 (rev 403) had one blocking item, R3, +plus N9 and three cheap tests. Dewey's comment 27278 (rev 407) had one +blocking item, B1 again, plus two non-blocking ones. Dewey asked for one +rule covering B1 and R3, and this round has one. `build.patch`, +`files.txt` and `candidate-manifest.sha256` now hold the round 3 +candidate. The round 2 versions are in this packet's history at +`fa6d889c`. The same nine files: 9 files, +1932/−16 against `fe5c68fa`. Base for the apply check: +`122b1223` (`out/gate-round3/apply.txt`). None of the nine files changed +between `fe5c68fa` and `122b1223`. + +### Blocking: R3 and B1, one rule + +**The defects.** R3 (Filbert): below 40x10 the frame shows only the +too-small text, and `y` still resolved the question it hid. B1 (Dewey): +between 40x10 and about 80x21 the decide box didn't fit, and `draw()`'s +general fallback cut it from the top and the bottom. The action, what +the choice authorizes, the recommendation and the task were dropped, +and at 80x10 and 40x12 "your choice:" too, while `y` still resolved. + +**The rule.** `y` or `Y` resolves only while the frame shows the whole +decide box, with the end of a long question as the one thing it may +cut. Every other case answers No and sends nothing. + +- `decideBox(o, w, room)` in `screens.mjs` builds the box. If it is + taller than the body, it cuts the end of the question and keeps the + rest: the decision line, the options, the recommendation, the action + and target, what the choice authorizes, the task, "your choice:", the + question and its keys. If those don't fit either, it returns null. +- `decideShown(o, cols, rows)` is true when the terminal is at least + 40x10 and `decideBox` returns a box. It is exported and `App#ask` + uses it: `answer((k === "y" || k === "Y") && decideShown(...))`. +- `App#ask` reads the size at each key, not when the question was + armed. A terminal shrunk after the question appeared answers No, and + one grown back first lets `y` resolve. +- When `decideBox` returns null, `overlayBox` draws a small box: "Too + small to show this decision." and "Any key answers No." (or + "resolving…" when the question isn't open yet). +- Below 40x10 the too-small text ends "A decision question is open; any + key answers No." while the question is open, instead of "q quits", + since q answers No there. +- `draw()`'s general fallback now applies only to help and to a long + detail. A decide box always fits by the time it reaches `draw()`. The + fallback no longer slices: the body is at least 5 rows, so top, cut + line and last two lines are exactly that many. + +The overlayBox comment and the README bullet said the must-keep lines +were never lost. Both now describe the rule and the hidden box. The +README states the sizes I measured for a gated decision with a task: +20 rows at 80 columns, 21 with a cut question, and 29 rows at 40 +columns. `out/probe-round3.mjs` measured them, and its output is +`out/probe-round3.txt`. The smallest row count per width, short question +40:29, 50:26, 60:22, 80:20, 120:20; long question 40:30, 50:27, 60:23, +80:21, 120:21. + +**Tests.** + +- "below 40x10 the question is hidden, so every key answers it No, y + included (Filbert R3)": at 30x24 and 80x9 from the start, and at 80x24 + shrunk to 30x24 after arming, `y`/`Y` sends nothing, the status is + `not resolved` and the decision stays in the Inbox. The frame shows + the new text and not "q quits". Shrunk and grown back before the key, + `y` resolves. +- "a decide box too big for the terminal says so, and every key answers + it No, y included (Dewey B1, round 3)": a gated decision with a task. + Dewey's sizes, short question at 80x18 and 40x24 and long question at + 80x20, plus 120x16 and 80x10: the frame says "Too small to show this + decision" and `y` sends nothing. At 80x20 short and 80x21 long, the + smallest sizes that show it, every must-keep line is on screen and `y` + resolves. Armed at 80x24 and shrunk to 80x15 before the key, `Y` + answers No. +- "at every size the decide box shows each line y depends on, or says + it is hidden": columns 40 to 120 in steps of 4, rows 10 to 40, short + and long questions. At each size either `decideShown` is true and the + frame has all ten must-keep lines, or it is false and the frame has + the hidden box. Each side holds more than 100 sizes. +- The "every screen and overlay fits" test now expects the hidden box + for its long overlay at the narrow layout, not a cut box. + +### Non-blocking + +**Dewey: `start-unguarded` survived.** The runApp test now has a case +where the terminal's write throws on the first frame. runApp must +reject with that error and leave the terminal as it found it. The +`.then` handler is attached at the call, because the first frame's +error rejects before the next tick. + +**Dewey: four mutants were killed only by a hang.** `{ timeout: 10000 }` +is now on "decide with --note still asks", "quitting at the question", +and the runApp test, which covers `no-sigterm` and `no-sighup`. + +**N9 (Filbert).** `cells()` now counts the remaining East Asian Width W +and F ranges as 2: U+2329, U+232A, U+A960-A97C, U+FE10-FE19, +U+FE50-FE6B, U+16FE0-16FF1, U+17000-18D08, U+1AFF0-1B2FB. The width +test checks one character from each, plus U+1AFF0's range end, U+1B2FB. + +**Cheap tests (Filbert).** + +- U+1F210 measures 2 (width test). +- A throw from `answer`. The test makes the terminal write throw after the + question is drawn, then presses `y`. This found a defect. `answer` threw + from its `render()` before calling `resolve`, so the decide job never + ended: the pump crashed the app, but `this.job` stayed pending. + `answer` now resolves No when its render throws, then rethrows to the + pump. The test checks the quit is `an error` with that error, the job + ends, and nothing is sent. +- ❤️ at a wrap edge. This also found a defect. `wrap()` broke long words + between code points, so `wrap("abc❤️ x", 4)` gave `abc❤` and a line + starting with a lone U+FE0F. It now breaks between graphemes + (`Intl.Segmenter`), so the result is `abc` and `❤️ x`. The test also + checks a line of marks and ❤️ never comes out wider than asked. + +The "runLoop never overlaps ticks" flake is in the notifier tests, not +in this candidate. I left it to the notifier's owner. + +### Tests, round 3 + +`packages/cli/tests/app.test.mjs` has 27 tests (23 in round 2); the cli +suite has 110. New: the R3 test, the answer-throw test and the two B1 +tests. The width test gained the N9 characters, U+1F210 and the wrap +checks. The runApp test gained the first-frame error case. + +### Evidence, round 3 + +All runs were in a detached worktree of `122b1223` with the round 3 +`build.patch` applied and `node_modules` linked. `out/gate-round3/` has +the logs, `summary.txt` and `gate.sh`, the script that ran them in +sequence. + +- `packages/cli`: 110/110, `out/gate-round3/cli-tests.log`. +- Every `scripts/test-*.sh` exited 0: + + | Suite | Result | + |---|---| + | test-auth | 15 passed, 0 failed | + | test-conductor | 17 passed, 0 failed | + | test-config | 24 passed, 0 failed | + | test-discord | 66 passed, 0 failed | + | test-extension-package | 18 passed, 0 failed | + | test-foundation | 44 passed, 0 failed | + | test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree | + | test-release | 14 passed, 0 failed | + | test-task | 26 passed, 0 failed; Docker groups skipped, as in rounds 1 and 2 | + + A `gate_default` Docker network appeared during the run. It had no + containers afterwards, and I removed it. +- `out/gate-round3/apply.txt`: `build.patch` applies to `122b1223` and + the manifest matches. +- `out/gate-round3/smoke.txt`: `smoke.sh` at 80x24 against a fixture + config, never the real bus. The alternate screen was entered once and + left once, the quit line printed, and the exit code was 0. +- `out/probe-round3.mjs` and `out/probe-round3.txt`: the size probe the + README numbers come from. Run it with `CLI=/packages/cli node + --test out/probe-round3.mjs`. + +- `out/mutants-round3/`: `run3.mjs` and its output `round3.txt`, 51 + mutants. Round 2's 30 less `overlay-cut-loses-prompt`, which edited a + line this round removed. Four re-anchored to lines this round changed + (`other-key-waits`, `no-capital-y`, `tiny-no-guard`, + `b1-cut-keeps-end`). Then 22 for round 3, among them + `r3-y-ignores-shown`, `r3-shown-is-min-size` (y checks only 40x10), + `r3-shown-at-arm` (the size read once, when armed), + `r3-no-question-whole-box` and `r3-start-unguarded`. All 51 were + killed, and the three source files matched the candidate afterwards. + One run, no fix needed. +- Candidate manifest sha256: `59f1b9b4dabfb47dc79641de209317cfd538cb27bd484b5391a0703a8c84c60b`. diff --git a/agents/darkwing/work/queue-55/build.patch b/agents/darkwing/work/queue-55/build.patch index bb7e836f6..455dc7121 100644 --- a/agents/darkwing/work/queue-55/build.patch +++ b/agents/darkwing/work/queue-55/build.patch @@ -22,7 +22,7 @@ index b96e9012..5a675611 100644 `3` refused or config problem · `4` usage. Details: `packages/cli/README.md`. diff --git a/packages/cli/README.md b/packages/cli/README.md -index 32977afa..dbb03138 100644 +index 32977afa..8114753c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`, @@ -44,7 +44,7 @@ index 32977afa..dbb03138 100644 scripts/mosaic inbox scripts/mosaic decide 3f2a9c1e yes --note "ship it" scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7 -@@ -70,6 +74,65 @@ mosaic launches list [--json] +@@ -70,6 +74,74 @@ mosaic launches list [--json] `launches list` does not, because the file is readable to the same user anyway. @@ -92,13 +92,22 @@ index 32977afa..dbb03138 100644 + visible without colour). Frames fit 80x24; the layout is designed at + 120x36, and under 100 columns the nav shows keys only and the Inbox + drops RAISED BY. Below 40x10 the frame only says the terminal is too -+ small. Widths count terminal cells: CJK and emoji 2, a one-cell emoji -+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw, -+ so such a row can come out short, never too wide. -+- A decide box taller than the screen cuts the question, not the lines -+ after it: the action and target, what the choice authorizes, the task, -+ your choice and the `[y/N]` question stay. The cut line points to -+ `mosaic inbox --json`. ++ small. If the decide question is open then, the frame says so, and any ++ key answers it No, `y` included. Widths count terminal cells: East ++ Asian Width W and F characters and emoji 2, a one-cell emoji base with ++ U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a ++ row can come out short, never too wide. Wrapping breaks between ++ graphemes, so U+FE0F and combining marks stay with their base. ++- A decide box taller than the screen cuts the end of the question and ++ nothing else: the decision line, the options, the recommendation, the ++ action and target, what the choice authorizes, the task, your choice ++ and the `[y/N]` question stay. The cut line points to `mosaic inbox ++ --json`. When even those lines don't fit, the box says only "Too small ++ to show this decision. Any key answers No." `y` resolves only while the ++ frame shows the whole box apart from the cut, and the app checks the ++ size at each key, so a terminal shrunk after the question appeared ++ answers No. A gated decision with a task needs 20 rows at 80 columns ++ (21 with a cut question), and 29 rows at 40 columns. +- The transport is the verbs' `spawnSync` child, so the screen does not + redraw while a read is in flight (up to its 30 s timeout). +- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a @@ -112,10 +121,10 @@ index 32977afa..dbb03138 100644 ``` diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs new file mode 100644 -index 00000000..3cb3ab71 +index 00000000..3f5418b0 --- /dev/null +++ b/packages/cli/src/app.mjs -@@ -0,0 +1,480 @@ +@@ -0,0 +1,489 @@ +// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs +// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as +// the verbs; src/screens.mjs draws the frames. @@ -137,7 +146,7 @@ index 00000000..3cb3ab71 +import { resolveDecision } from "./cli.mjs"; +import { CliError } from "./errors.mjs"; +import { formatDecision } from "./format.mjs"; -+import { draw } from "./screens.mjs"; ++import { decideShown, draw } from "./screens.mjs"; +import { busExit } from "./transport.mjs"; + +export const REFRESH_MS = 30000; @@ -282,7 +291,10 @@ index 00000000..3cb3ab71 + // any other key. Only a key typed after the question is up can answer: + // keys that arrive within askMs of drawing it are type-ahead (a paste, or + // keys typed while a read blocked the screen) and are dropped. Quitting -+ // answers no. ++ // answers no. y counts only while the frame shows the whole box, the end ++ // of a long question aside (decideShown); on a terminal too small for it, ++ // every key answers no, y included. The size is read at each key, so a ++ // shrink after the question is armed is covered. + #ask(question) { + this.state.overlay = { ...this.state.overlay, asking: question }; + this.render(); @@ -291,13 +303,19 @@ index 00000000..3cb3ab71 + clearTimeout(arm); + this.answer = this.cancelAsk = null; + this.state.overlay = { ...this.state.overlay, asking: null }; -+ this.render(); ++ try { ++ this.render(); ++ } catch (e) { ++ // The pump sends this to crash; the decide flow ends with No. ++ resolve(false); ++ throw e; ++ } + resolve(yes); + }; + this.cancelAsk = () => answer(false); + const arm = setTimeout(() => { + this.keys = []; -+ this.answer = (k) => answer(k === "y" || k === "Y"); ++ this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows)); + this.wake?.(); + }, this.askMs); + }); @@ -708,10 +726,10 @@ index 381596b4..7bbfe39c 100644 return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" "); diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs new file mode 100644 -index 00000000..c0194cb2 +index 00000000..38ac5f94 --- /dev/null +++ b/packages/cli/src/screens.mjs -@@ -0,0 +1,405 @@ +@@ -0,0 +1,429 @@ +// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes +// the app state and returns exactly `rows` strings, each `cols` cells wide +// once its escape codes are dropped. Nothing here reads the bus or the @@ -750,12 +768,20 @@ index 00000000..c0194cb2 + if ( + /\p{Emoji_Presentation}/u.test(c) || + (n >= 0x1100 && n <= 0x115f) || ++ n === 0x2329 || ++ n === 0x232a || + (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) || ++ (n >= 0xa960 && n <= 0xa97c) || + (n >= 0xac00 && n <= 0xd7a3) || + (n >= 0xf900 && n <= 0xfaff) || ++ (n >= 0xfe10 && n <= 0xfe19) || + (n >= 0xfe30 && n <= 0xfe4f) || ++ (n >= 0xfe50 && n <= 0xfe6b) || + (n >= 0xff00 && n <= 0xff60) || + (n >= 0xffe0 && n <= 0xffe6) || ++ (n >= 0x16fe0 && n <= 0x16ff1) || ++ (n >= 0x17000 && n <= 0x18d08) || ++ (n >= 0x1aff0 && n <= 0x1b2fb) || + (n >= 0x1f200 && n <= 0x1f2ff) || + (n >= 0x20000 && n <= 0x3fffd) + ) @@ -790,8 +816,11 @@ index 00000000..c0194cb2 + return out + "…" + " ".repeat(w - 1 - used); +} + ++const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); ++ +// Splits `s` into lines of at most `w` cells, at spaces where it can; -+// later lines start with `indent`. ++// later lines start with `indent`. A long word breaks between graphemes, ++// so a mark or U+FE0F stays with its base. +export function wrap(s, w, indent = "") { + const out = []; + let cur = ""; @@ -801,7 +830,7 @@ index 00000000..c0194cb2 + }; + for (const word of s.split(/(?<= )/)) { + if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push(); -+ for (const c of word) { ++ for (const { segment: c } of graphemes.segment(word)) { + if (width(cur + c) > w) push(); + cur += c; + } @@ -1052,23 +1081,35 @@ index 00000000..c0194cb2 +// `room` is the body's height. A decide box taller than that loses the end +// of the question, never the decision line, the options, the +// recommendation, the authorization lines, the task, the choice, the -+// question to answer or its keys. ++// question to answer or its keys. If those don't fit either, the box only ++// says the decision is hidden, and App#ask answers every key No. ++function decideBox(o, w, room) { ++ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t)); ++ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]); ++ else lines.push("", ["resolving…", "muted"]); ++ const b = box("decide", lines, w); ++ if (b.length <= room) return b; ++ const q = o.lines.findIndex((t) => t.startsWith(" question: ")); ++ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: ")); ++ if (q < 0 || end < 0) return null; ++ const head = box("decide", lines.slice(0, q), w).slice(0, -1); ++ const question = box("decide", lines.slice(q, end), w).slice(1, -1); ++ const tail = box("decide", lines.slice(end), w).slice(1); ++ const keep = room - head.length - tail.length - 1; ++ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : null; ++} ++ ++// Whether a frame of cols x rows shows the whole decide box, the end of a ++// long question aside. y resolves only then (App#ask). ++export function decideShown(o, cols, rows) { ++ return cols >= MIN_COLS && rows >= MIN_ROWS && o?.kind === "decide" && decideBox(o, Math.min(84, cols - 2), rows - 5) !== null; ++} ++ +function overlayBox(o, cols, room) { + if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2)); + const w = Math.min(84, cols - 2); + if (o.kind === "decide") { -+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t)); -+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]); -+ else lines.push("", ["resolving…", "muted"]); -+ const b = box("decide", lines, w); -+ const q = o.lines.findIndex((t) => t.startsWith(" question: ")); -+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: ")); -+ if (b.length <= room || q < 0 || end < 0) return b; -+ const head = box("decide", lines.slice(0, q), w).slice(0, -1); -+ const question = box("decide", lines.slice(q, end), w).slice(1, -1); -+ const tail = box("decide", lines.slice(end), w).slice(1); -+ const keep = room - head.length - tail.length - 1; -+ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : b; ++ return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"]], w); + } + if (o.kind === "unknown") { + return box("outcome unknown", [[o.message, "warning"], "", "Nothing was retried, and the choice was not sent twice.", "", "v open the trail r re-read the inbox Esc back"], w); @@ -1079,7 +1120,7 @@ index 00000000..c0194cb2 + +// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms). +export function draw(state, { cols, rows, color = true, now = Date.now() }) { -+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color); ++ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking)); + const room = rows - 5; + let body; + if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()]; @@ -1092,14 +1133,13 @@ index 00000000..c0194cb2 + while (body.length < room) body.push(blank()); + if (state.overlay) { + let b = overlayBox(state.overlay, cols, room); -+ // Still too tall (a small screen, or a box with no question): keep the -+ // top and, for decide, everything from the choice on; past that, the -+ // bottom wins, so the question to answer and its keys stay. ++ // Still too tall (help, or a long detail on a small screen; a decide ++ // box always fits): the top, a cut line and the last two lines. room is ++ // at least 5, so that is exactly room lines. + if (b.length > room) { -+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: "))); -+ const tail = b.slice(at > 0 ? at : -2); ++ const tail = b.slice(-2); + const inner = Math.min(84, cols - 2) - 4; -+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail].slice(-room); ++ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail]; + } + const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2)); + const top = Math.max(0, Math.min(2, room - b.length)); @@ -1111,18 +1151,20 @@ index 00000000..c0194cb2 +} + +// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit. -+function tooSmall(cols, rows, color) { -+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. Esc closes what is open; q quits.`, Math.max(1, cols)); ++// With the decide question open, any key answers it No (App#ask). ++function tooSmall(cols, rows, color, asking) { ++ const keys = asking ? "A decision question is open; any key answers No." : "Esc closes what is open; q quits."; ++ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. ${keys}`, Math.max(1, cols)); + const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"])); + while (body.length < rows) body.push(blank()); + return body.map((l) => paint(l, Math.max(0, cols), color)); +} diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs new file mode 100644 -index 00000000..b01abcb6 +index 00000000..a679886a --- /dev/null +++ b/packages/cli/tests/app.test.mjs -@@ -0,0 +1,719 @@ +@@ -0,0 +1,868 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; @@ -1134,7 +1176,7 @@ index 00000000..b01abcb6 +import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs"; +import { CliError } from "../src/errors.mjs"; +import { hostDir, hostFile, startTimeOf } from "../src/host.mjs"; -+import { draw, strip, width } from "../src/screens.mjs"; ++import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs"; +import { broker, humanEnv, io, tmp } from "./helpers.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); @@ -1291,11 +1333,11 @@ index 00000000..b01abcb6 + for (const color of [true, false]) { + for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`); + } -+ // A decide box taller than the screen keeps its question and keys. ++ // A decide box too tall to show, with no question to cut, says the ++ // decision is hidden and that any key answers No (Dewey B1, round 3). + const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n"); -+ assert.match(long, /resolve 11111111 with yes\?/, `${cols}x${rows}`); -+ assert.match(long, /y resolves/, `${cols}x${rows}`); -+ if (rows >= 24) assert.match(long, /cut to fit[\s\S]*your choice: yes \(Allow\)/, `${cols}x${rows}`); ++ assert.match(long, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`); ++ assert.doesNotMatch(long, /y resolves/, `${cols}x${rows}`); + } + // Too small: every screen still fits, and says why. + for (const [cols, rows] of TOO_SMALL) { @@ -1312,9 +1354,14 @@ index 00000000..b01abcb6 +}); + +test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => { -+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1]]) { ++ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) { + assert.equal(width(s), cells, JSON.stringify(s)); + } ++ // East Asian Width W and F outside the blocks above: one from each range. ++ for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16)); ++ // A line break never splits ❤️ from its U+FE0F; no line is wider than asked. ++ assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]); ++ for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l)); + // On screen, independent of width(): each wide character takes two cells + // for one UTF-16 unit, so a row of `cols` cells is that much shorter in + // units. 🅰️ is three units for two cells. @@ -1419,6 +1466,69 @@ index 00000000..b01abcb6 + } +}); + ++test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => { ++ const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " "); ++ const notSent = (s, label) => { ++ assert.equal(s.a.state.overlay, null, label); ++ assert.equal(s.a.state.status, "not resolved", label); ++ assert.equal(s.read("inbox").length, 1, label); ++ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label); ++ }; ++ // Too small from the start: the frame says a question is open, and y answers No. ++ for (const [cols, rows] of [[30, 24], [80, 9]]) { ++ const s = app(t, { cols, rows }); ++ s.raise("git.push.protected", { target: "refactor" }); ++ await s.a.start(); ++ await s.a.input("1"); ++ assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`); ++ assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`); ++ assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`); ++ await s.a.input("y"); ++ notSent(s, `${cols}x${rows}`); ++ assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone"); ++ } ++ // Shrunk while the question is open. ++ const s = app(t); ++ s.raise("git.push.protected", { target: "refactor" }); ++ await s.a.start(); ++ await s.a.input("1"); ++ assert.match(s.screen(), /y resolves/); ++ s.a.resize(30, 24); ++ assert.match(flat(s), /A decision question is open; any key answers No\./); ++ await s.a.input("Y"); ++ notSent(s, "shrunk"); ++ // Shrunk and grown back before the key: the question is on screen again, so y resolves. ++ const g = app(t); ++ const d = g.raise("git.push.protected", { target: "refactor" }); ++ await g.a.start(); ++ await g.a.input("1"); ++ g.a.resize(30, 24); ++ g.a.resize(80, 24); ++ assert.match(g.screen(), /y resolves/); ++ await g.a.input("y"); ++ assert.equal(g.a.state.status, `resolved ${d.id}: yes`); ++}); ++ ++test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => { ++ const boom = new Error("the terminal write failed"); ++ let fail = false; ++ const s = app(t, { ++ write: () => { ++ if (fail) throw boom; ++ }, ++ }); ++ s.raise("git.push.protected", { target: "refactor" }); ++ await s.a.start(); ++ await s.a.input("1"); ++ assert.ok(s.a.state.overlay?.asking); ++ fail = true; ++ await s.a.input("y"); ++ assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom }); ++ for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r)); ++ assert.equal(s.a.job, null, "the decide job ended"); ++ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); ++}); ++ +test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => { + const asking = (s) => Boolean(s.a.state.overlay?.asking); + const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length; @@ -1471,7 +1581,7 @@ index 00000000..b01abcb6 + } +}); + -+test("quitting at the question answers No and sends nothing, armed or not", async (t) => { ++test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => { + for (const askMs of [0, 60000]) { + const s = app(t, { askMs }); + s.raise("git.push.protected", { target: "refactor" }); @@ -1529,6 +1639,81 @@ index 00000000..b01abcb6 + fits(s.a.frame, 80, 24, "long question"); +}); + ++// A gated, blocking decision with a task, and every line its decide box ++// must show before y can resolve it (Dewey B1, round 3). ++function gated(t, cols, rows, long) { ++ const s = app(t, { cols, rows }); ++ const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?"; ++ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question }); ++ const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"]; ++ return { ...s, d, keep }; ++} ++// Box borders and wrapping removed, so a line broken across rows still matches. ++const squash = (text) => text.replace(/[\s│]/g, ""); ++ ++test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => { ++ const hidden = async (s, key, label) => { ++ assert.ok(s.a.state.overlay?.asking, label); ++ assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label); ++ assert.doesNotMatch(s.screen(), /y resolves/, label); ++ await s.a.input(key); ++ assert.equal(s.a.state.overlay, null, label); ++ assert.equal(s.a.state.status, "not resolved", label); ++ assert.equal(s.read("inbox").length, 1, label); ++ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label); ++ }; ++ // Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20. ++ for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) { ++ const s = gated(t, cols, rows, long); ++ await s.a.start(); ++ await s.a.input("1"); ++ await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`); ++ } ++ // One row more and the whole box shows, so y resolves. ++ for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) { ++ const s = gated(t, cols, rows, long); ++ await s.a.start(); ++ await s.a.input("1"); ++ for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`); ++ await s.a.input("y"); ++ assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`); ++ } ++ // Shrunk after the question is armed: checked at the key. ++ const s = gated(t, 80, 24, false); ++ await s.a.start(); ++ await s.a.input("1"); ++ assert.match(s.screen(), /y resolves/); ++ s.a.resize(80, 15); ++ await hidden(s, "Y", "shrunk to 80x15"); ++}); ++ ++test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => { ++ for (const long of [false, true]) { ++ const s = gated(t, 120, 40, long); ++ await s.a.start(); ++ await s.a.input("1"); ++ const seen = { shown: 0, hidden: 0 }; ++ for (let cols = 40; cols <= 120; cols += 4) { ++ for (let rows = 10; rows <= 40; rows++) { ++ const frame = draw(s.a.state, { cols, rows, color: false, now: NOW }); ++ const label = `${cols}x${rows}${long ? " long" : ""}`; ++ fits(frame, cols, rows, label); ++ const text = frame.map(strip).join("\n"); ++ if (decideShown(s.a.state.overlay, cols, rows)) { ++ seen.shown++; ++ for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`); ++ } else { ++ seen.hidden++; ++ assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label); ++ assert.doesNotMatch(text, /y resolves/, label); ++ } ++ } ++ } ++ assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen)); ++ s.a.quit("q"); ++ } ++}); ++ +test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => { + let resolves = 0; + const s = app(t, { @@ -1759,7 +1944,7 @@ index 00000000..b01abcb6 + } +}); + -+test("decide with --note still asks; a No sends nothing", async (t) => { ++test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => { + const s = broker(t); + const d = s.raise("git.push.protected", { target: "refactor" }); + const call = s.transport("demo"); @@ -1795,9 +1980,13 @@ index 00000000..b01abcb6 +}); + +// A terminal for runApp: raw mode and listeners recorded, writes kept. -+function terminal({ throwOnResize = false } = {}) { ++function terminal({ throwOnResize = false, throwOnFrame = false } = {}) { + const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} }); -+ const stdout = Object.assign(new EventEmitter(), { writes: [], write: (s) => stdout.writes.push(s), rows: 24 }); ++ const write = (s) => { ++ if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed"); ++ stdout.writes.push(s); ++ }; ++ const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 }); + let resized = false; + Object.defineProperty(stdout, "columns", { + get: () => { @@ -1810,7 +1999,7 @@ index 00000000..b01abcb6 + return { io: { stdin, stdout }, stdin, stdout, signals, resize }; +} + -+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error", async () => { ++test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => { + const cases = [ + ["q", (x) => x.stdin.emit("data", Buffer.from("q"))], + ["end of input", (x) => x.stdin.emit("end")], @@ -1818,18 +2007,20 @@ index 00000000..b01abcb6 + ["SIGINT", (x) => x.signals.emit("SIGINT")], + ["SIGHUP", (x) => x.signals.emit("SIGHUP")], + ["an error", (x) => x.resize(), { throwOnResize: true }], ++ ["an error", () => {}, { throwOnFrame: true }], + ]; + for (const [how, act, opts] of cases) { + const x = terminal(opts); -+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }); -+ await new Promise((r) => setImmediate(r)); -+ act(x); -+ const result = await running.then( ++ // Handled at once: an error on the first frame rejects before the next tick. ++ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then( + (r) => r, + (e) => ({ how: "an error", error: e }), + ); ++ await new Promise((r) => setImmediate(r)); ++ act(x); ++ const result = await running; + assert.equal(result.how, how); -+ if (how === "an error") assert.equal(result.error.message, "columns failed"); ++ if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed"); + assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how); + assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how); + assert.deepEqual(x.stdin.raw, [true, false], how); diff --git a/agents/darkwing/work/queue-55/candidate-manifest.sha256 b/agents/darkwing/work/queue-55/candidate-manifest.sha256 index 14ce33108..98a2b49d6 100644 --- a/agents/darkwing/work/queue-55/candidate-manifest.sha256 +++ b/agents/darkwing/work/queue-55/candidate-manifest.sha256 @@ -1,9 +1,9 @@ fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md -ac058d0aa7eaaf7a52a681f79fcedb3ea15051226697542029cb5fa15fc15600 packages/cli/README.md -dfdeb9b91eb41f67ecec8e4835f631d6ec47b967897d349ed9b27d06045a9d93 packages/cli/src/app.mjs +08fbf0fd72ca5832a33b189a3a64c89dc10d752bfbb94b99042125e9f38cd30a packages/cli/README.md +6dd4cc049bdb166bcd63b708093e87bc77726001b83a5b0091bc70a24dc3dc3e packages/cli/src/app.mjs c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs -ff24eb0f78a64f98892eca372337ce141f30bc15169200fde37b6db34c6c8474 packages/cli/src/screens.mjs -10b15b25f607100f5a1a6e315334668c014d01452c92fbd1c04dd2bfba276d30 packages/cli/tests/app.test.mjs +3cc80840f7492b3271835cc88bd99420566b9079098fef8adb9d0d4258a2921c packages/cli/src/screens.mjs +1b7b31f0f438a6390315a824b0e300ab78f4caaa6885f86705a08860638ebd47 packages/cli/tests/app.test.mjs bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic diff --git a/agents/darkwing/work/queue-55/out/gate-round3/apply.txt b/agents/darkwing/work/queue-55/out/gate-round3/apply.txt new file mode 100644 index 000000000..725bd58d1 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/apply.txt @@ -0,0 +1,13 @@ +round 3 apply check, 2026-10-11T19:52:01Z +base for apply: 122b122374ce93523efa13cd30676918c416f106 +git apply --index build.patch: ok + 9 files changed, 1932 insertions(+), 16 deletions(-) +docs/TOOLS.md: OK +packages/cli/README.md: OK +packages/cli/src/app.mjs: OK +packages/cli/src/cli.mjs: OK +packages/cli/src/format.mjs: OK +packages/cli/src/screens.mjs: OK +packages/cli/tests/app.test.mjs: OK +packages/queue/tests/dispatch.test.mjs: OK +scripts/mosaic: OK diff --git a/agents/darkwing/work/queue-55/out/gate-round3/cli-tests.log b/agents/darkwing/work/queue-55/out/gate-round3/cli-tests.log new file mode 100644 index 000000000..e039a007c --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/cli-tests.log @@ -0,0 +1,120 @@ +✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1641.123987ms) +✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (91.625067ms) +✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (204.389251ms) +✔ NO_COLOR: no escape codes, except reverse video on the selected row (297.651664ms) +✔ 1-9 shows the decide flow's own lines and question; only y resolves (134.985026ms) +✔ the app shows the same decide text as mosaic decide (65.507892ms) +✔ any key but y or Y at the question answers No; nothing is sent (1393.693801ms) +✔ below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3) (323.453488ms) +✔ a throw while answering the question goes to crash, sends nothing and leaves no job pending (100.439482ms) +✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (1158.438577ms) +✔ quitting at the question answers No and sends nothing, armed or not (173.081799ms) +✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (80.1729ms) +✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (89.855673ms) +✔ a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3) (700.017659ms) +✔ at every size the decide box shows each line y depends on, or says it is hidden (11063.134882ms) +✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (170.541821ms) +✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (114.46264ms) +✔ keys: views, moving, trail of a selection, the command line, help and quit (250.788525ms) +✔ one option: the status line says Press 1, not 1-1 (108.728774ms) +✔ no business: the app says so and :business picks one (54.240423ms) +✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (1.82506ms) +✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (211.846348ms) +✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.784937ms) +✔ decide with --note still asks; a No sends nothing (61.39156ms) +✔ the app's business comes from a live bus host only (1.253556ms) +✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included (9.450749ms) +✔ draw is pure: same state, same frame (1.014652ms) +✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (160.228747ms) +✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (173.981743ms) +✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (178.01175ms) +✔ decide prints a declining choice as declining (186.91029ms) +✔ an unknown outcome is reported once and never resent (114.294151ms) +✔ a decision closed before the answer arrives exits 2 and points at its trail (228.413474ms) +✔ a prefix that matches two open decisions exits 2 and resolves neither (127.665424ms) +✔ without --business a command uses the live host's business, and a stale host.json is not a host (103.648788ms) +✔ every human command refuses inside an agent run before it touches the bus (85.556081ms) +✔ usage errors exit 4; no business and no host is a usage error (77.506148ms) +✔ agents and tasks print through the broker (103.476436ms) +✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.603133ms) +✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (68.648006ms) +✔ trackers come from the tracker.* variables of the one project that names a tracker project (42.037548ms) +✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (45.478142ms) +✔ two projects that each name a tracker project refuse, since the boot shape holds one (41.178111ms) +✔ a business without tracker.baseUrl gets no trackers entry (36.924054ms) +✔ an unknown business and a broken system config refuse with exit 3 (80.616129ms) +✔ empty views say so (1.155964ms) +✔ the trail keeps the broker's order and names a decision's task without its rows (1.417509ms) +✔ tasks print the tracker fields the snapshot carries (0.207258ms) +✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1017.640757ms) +✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (301.029227ms) +✔ a second host for the same data root refuses with exit 3 while the first runs (181.630331ms) +✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1184.644329ms) +✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (629.304517ms) +✔ a broker reply with no request waiting breaks the channel and the host exits 1 (170.37376ms) +✔ a notifier that refuses stops the broker and the host refuses with exit 3 (356.511996ms) +✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (228.131282ms) +✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (148.593046ms) +✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (725.077976ms) +✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (137.715237ms) +✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (195.313058ms) +✔ watchChildren reports a child that died before it was called, and one that dies later (33.728711ms) +✔ bus stop refuses to signal a live pid that is not a bus host (204.116213ms) +✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (285.402332ms) +✔ bus start refuses with exit 3 without a notifier config (120.124432ms) +✔ bus start runs until bus stop; status reports it while it runs (889.37193ms) +✔ bus-service.sh renders the unit and installs it into a given directory (41.46377ms) +✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1381.127614ms) +✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (736.176939ms) +✔ a runner that stops at once ends its launch with the runner's reason (231.500014ms) +✔ 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 (1198.727217ms) +✔ 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 (3819.084307ms) +✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (139.667775ms) +✔ an over-long launch request is refused at once, not at the 10 s idle timeout (152.104942ms) +✔ a launch client that never closes its side doesn't hold the host's close (148.765746ms) +✔ a runner that ignores SIGTERM is killed when the host closes (1266.599402ms) +✔ zoned uses the IANA zone across DST (30.213719ms) +✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (145.882722ms) +✔ two blocking decisions get two DMs with different nonces (155.970689ms) +✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.394037ms) +✔ a failed DM is journaled, backs off, and is retried until it lands (196.602478ms) +✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (177.977332ms) +✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (142.200148ms) +✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (200.443925ms) +✔ 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 (224.492906ms) +✔ a restart after the second refusal does not send before that refusal's 30 min are up (157.445891ms) +✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (118.512391ms) +✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (116.363451ms) +✔ an inbox read failure is logged and the next poll retries (0.899012ms) +✔ no Discord id reaches the journal or the log (102.631507ms) +✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (26.753715ms) +✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (62.706102ms) +✔ the journal: a whole file that is one torn line truncates to empty (21.628484ms) +✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.956257ms) +✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.751353ms) +✔ the journal: a line with a wrong type refuses with exit 3 and names the field (8.66405ms) +✔ the journal: a symlinked directory refuses and says it is a link (0.453577ms) +✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.611063ms) +✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.507789ms) +✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.50937ms) +✔ digest content stays within Discord's 2000 characters (0.312002ms) +✔ runLoop never overlaps ticks and stops after the one in flight (111.79346ms) +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 (455.287493ms) +✔ the transport writes {business, verb, args} to the child and reads its JSON (53.100004ms) +✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2188.870587ms) +✔ busExit and refuseInsideAgent (0.676038ms) +✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (265.655593ms) +✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1221.198035ms) +✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (109.069472ms) +✔ launches off and on go to the broker and change the business's launch state (101.594541ms) +✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (133.681892ms) +ℹ tests 110 +ℹ suites 0 +ℹ pass 110 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 18672.304682 diff --git a/agents/darkwing/work/queue-55/out/gate-round3/gate.sh b/agents/darkwing/work/queue-55/out/gate-round3/gate.sh new file mode 100644 index 000000000..5bc9179bc --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/gate.sh @@ -0,0 +1,10 @@ +#!/bin/bash +export TMPDIR=~/darkwing-scratch/r55/tmp +G=~/darkwing-scratch/r55/gate; O=~/darkwing-scratch/r55/out3; mkdir -p $O; cd $G +env -u NODE_TEST_CONTEXT node --test 'packages/cli/tests/*.test.mjs' > $O/cli-tests.log 2>&1; echo "cli rc=$?" >> $O/summary.txt +for s in scripts/test-*.sh; do + n=$(basename $s .sh) + if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $s > $O/$n.log 2>&1; else bash $s > $O/$n.log 2>&1; fi + echo "$n rc=$?" >> $O/summary.txt +done +echo DONE >> $O/summary.txt diff --git a/agents/darkwing/work/queue-55/out/gate-round3/smoke.txt b/agents/darkwing/work/queue-55/out/gate-round3/smoke.txt new file mode 100644 index 000000000..077d1fe0c --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/smoke.txt @@ -0,0 +1,4 @@ +(app closed with q; 0 decisions resolved this session) +EXIT=0 +alternate screen entered: 1 +alternate screen left: 1 diff --git a/agents/darkwing/work/queue-55/out/gate-round3/summary.txt b/agents/darkwing/work/queue-55/out/gate-round3/summary.txt new file mode 100644 index 000000000..ac980f6f7 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/summary.txt @@ -0,0 +1,11 @@ +cli rc=0 +test-auth rc=0 +test-conductor rc=0 +test-config rc=0 +test-discord rc=0 +test-extension-package rc=0 +test-foundation rc=0 +test-queue rc=0 +test-release rc=0 +test-task rc=0 +DONE diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-auth.log b/agents/darkwing/work/queue-55/out/gate-round3/test-auth.log new file mode 100644 index 000000000..ac2287606 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-auth.log @@ -0,0 +1,17 @@ +OK status with missing harness credential exits 3 and still lists accounts +OK status reports harness credential (read-only) + mosaic accounts +OK api key material never reaches output +OK oauth token material never reaches output +OK unparseable credential file exits 2 +OK symlinked credential file exits 4 +OK env-side credential names reported +OK env var values never reach output +OK accounts without an accounts dir reports none and creates nothing +OK accounts lists files and marks the active one +OK loose account perms flagged in listing +OK agent --auth with missing account file refuses (exit 4) +OK agent --auth with non-0600 account file refuses +OK agent --auth with invalid account name refuses +OK auth.sh without valid config refuses + +selftest: 15 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-conductor.log b/agents/darkwing/work/queue-55/out/gate-round3/test-conductor.log new file mode 100644 index 000000000..9180285f1 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-conductor.log @@ -0,0 +1,55 @@ +Note: switching to '122b122374ce93523efa13cd30676918c416f106'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +Not currently on any branch. +nothing to commit, working tree clean +Note: switching to '122b122374ce93523efa13cd30676918c416f106'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +OK dry-run: allowed change, exit 0, nothing committed (exit 0) +OK dry-run committed nothing +OK apply: allowed change exits 0 (exit 0) +OK apply: attribution in commit subject +OK apply: target tree clean after commit +OK disallowed path refused (exit 1) +OK disallowed path: target untouched +OK syntax gate refused broken .mjs (exit 1) +OK syntax gate: target untouched +OK suite failure refused (exit 1) +OK suite failure: target reverted to clean +OK disabled policy refused (exit 2) +OK disabled policy: target untouched +OK failed run refused (exit 1) +OK failed run: target untouched +OK missing run exits 4 (exit 4) +OK invalid policy exits 2 (exit 2) + +selftest: 17 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-config.log b/agents/darkwing/work/queue-55/out/gate-round3/test-config.log new file mode 100644 index 000000000..76c05ef74 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-config.log @@ -0,0 +1,26 @@ +OK absent adapter defaults to pi +OK adapter mock validates (exit 0) +OK unsupported adapter exits 2 (exit 2) +OK env exports adapter +OK bootstrap creates default when absent (exit 0) +OK bootstrap wrote config file +OK bootstrap is idempotent on existing config (exit 0) +OK bootstrap did not rewrite existing config +OK validate missing config exits 3 (exit 3) +OK malformed JSON exits 2 (exit 2) +OK unsupported configVersion exits 2 (exit 2) +OK unknown top-level key exits 2 (exit 2) +OK unknown execution key exits 2 (exit 2) +OK unsupported backend exits 2 (exit 2) +OK unsupported environment exits 2 (exit 2) +OK relative dataRoot exits 2 (exit 2) +OK non-canonical dataRoot exits 2 (exit 2) +OK filesystem root dataRoot exits 2 (exit 2) +OK home directory dataRoot exits 2 (exit 2) +OK dataRoot containing config dir exits 2 (exit 2) +OK control character in provider exits 2 (exit 2) +OK symlinked config file exits 2 (exit 2) +OK env exports resolve correctly +OK failed validation modified nothing + +selftest: 24 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-discord.log b/agents/darkwing/work/queue-55/out/gate-round3/test-discord.log new file mode 100644 index 000000000..b013f9ffa --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-discord.log @@ -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/queue-55/out/gate-round3/test-extension-package.log b/agents/darkwing/work/queue-55/out/gate-round3/test-extension-package.log new file mode 100644 index 000000000..52bc05599 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-extension-package.log @@ -0,0 +1,21 @@ +OK initial ordinary-file install +OK installed tree matches canonical source +OK installed tree has no symlinks +OK check detects installation drift +OK sync refuses to overwrite installation drift +OK check detects an extra destination file +OK check detects an extra destination directory +OK check rejects a destination symlink +OK sync accepts a canonical source update +OK updated installation matches canonical source +scripts/test-extension-package.sh: line 14: 3158942 Killed "$@" > /dev/null 2>&1 +OK forced interruption kills the replacing process +OK next invocation recovers old consistent installation +OK interrupted replacement rolled back +OK sync succeeds after interruption recovery +OK unlocked stale lock file does not block +OK active lock refuses a concurrent sync +OK source symlink fails closed +OK nested second entrypoint fails closed + +extension package selftest: 18 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-foundation.log b/agents/darkwing/work/queue-55/out/gate-round3/test-foundation.log new file mode 100644 index 000000000..6338e1e6d --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-foundation.log @@ -0,0 +1,53 @@ +toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0 + +OK syntax: scripts/foundation-inspect.mjs +OK syntax: scripts/foundation/strict-json.mjs +OK syntax: scripts/foundation/canonical.mjs +OK syntax: scripts/foundation/resolve.mjs +OK syntax: scripts/foundation/validate-record.mjs +OK syntax: scripts/foundation/fixtures/build-fixtures.mjs +OK syntax: scripts/foundation/canonical.test.mjs +OK syntax: scripts/foundation/cli.test.mjs +OK syntax: scripts/foundation/fixtures.test.mjs +OK syntax: scripts/foundation/resolve.test.mjs +OK syntax: scripts/foundation/strict-json.test.mjs +OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written) +OK fixture generator runs +OK checked-in fixtures/bundles equal a fresh generation +OK checked-in fixtures/raw equal a fresh generation +OK checked-in fixtures/index.json equal a fresh generation +OK checked-in demo bundles equal a fresh generation +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test scripts/foundation/ (ℹ pass 80) +OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) + platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999) + node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases) + schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10 + profile column (schema-valid records only): profile-valid 510, profile-invalid 30 + profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns +OK oracle: zero schema-column disagreements with the pinned checker +OK oracle: strict-only profile refusals are counted and asserted +OK demo: permitted read preview exits 0 (exit 0) +OK demo: permitted file.change preview exits 0 (exit 0) +OK demo: assignment.change proposal is unresolved (exit 3) (exit 3) +OK demo: revoked registration is refused (exit 3) (exit 3) +OK demo: message is not authority (exit 3) (exit 3) +OK usage: no arguments exits 2 (exit 2) +OK io: missing file exits 4 (exit 4) +OK io: directory exits 4 (exit 4) +OK io: symlink exits 4 (O_NOFOLLOW) (exit 4) +OK bound: oversize fixture exits 2 (exit 2) +OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2) +OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2) +OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0) +OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed +OK text output starts with the disclaimer +OK json output is valid JSON with result allowed and exactly the charter §7 fields +OK json golden matches byte-for-byte +OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal +OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs +OK canary never printed (bundle run and credential-file run) +OK a non-bundle JSON file is refused at the shape gate, not read into output +OK no field of the non-bundle file is echoed + +selftest: 44 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-queue.log b/agents/darkwing/work/queue-55/out/gate-round3/test-queue.log new file mode 100644 index 000000000..77aa5420f --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-queue.log @@ -0,0 +1,35 @@ +toolchain: node v26.8.1, git version 2.55.0 + +OK syntax: packages/queue/src/cli.mjs +OK syntax: packages/queue/src/errors.mjs +OK syntax: packages/queue/src/io.mjs +OK syntax: packages/queue/src/lock.mjs +OK syntax: packages/queue/src/queue.mjs +OK syntax: packages/queue/src/review.mjs +OK syntax: packages/queue/src/store.mjs +OK syntax: packages/queue/tests/commit.test.mjs +OK syntax: packages/queue/tests/data.test.mjs +OK syntax: packages/queue/tests/dispatch.test.mjs +OK syntax: packages/queue/tests/helpers.mjs +OK syntax: packages/queue/tests/lock.test.mjs +OK syntax: packages/queue/tests/migration.test.mjs +OK syntax: packages/queue/tests/review.test.mjs +OK syntax: packages/queue/tests/store.test.mjs +OK syntax: packages/queue/tests/write.test.mjs +OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs +OK syntax: packages/queue/tests/fixtures/kill-at.mjs +OK syntax: packages/queue/tests/fixtures/lock-child.mjs +OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh +OK syntax: scripts/queue-commit.sh +OK syntax: scripts/git-hooks/pre-commit +OK syntax: scripts/mosaic +OK queue-commit.sh, the guard and scripts/mosaic are executable +OK packages/queue declares no dependencies +ℹ tests 150 +ℹ pass 150 +ℹ fail 0 +OK node --test packages/queue/tests/ +OK scripts/mosaic queue help +skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r55/gate) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + +queue suite: 27 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-release.log b/agents/darkwing/work/queue-55/out/gate-round3/test-release.log new file mode 100644 index 000000000..7202b85d4 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-release.log @@ -0,0 +1,16 @@ +OK valid RELEASE resolves (exit 0) +OK invalid RELEASE exits 1 (exit 1) +OK missing RELEASE exits 1 (exit 1) +OK valid RELEASE leaves image tag consistent with version +OK status safe on empty state (exit 0) +OK status created no pointer +OK fault-injected activation refuses (exit 1) +OK refused activation wrote no pointer +OK refusal logged exactly once with valid fields +OK healthy activation succeeds (exit 0) +OK pointer written with valid fields +OK repeat activation succeeds (log grows) (exit 0) +OK log is append-only across activations +OK rollback without previous refuses (exit 1) + +selftest: 14 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/gate-round3/test-task.log b/agents/darkwing/work/queue-55/out/gate-round3/test-task.log new file mode 100644 index 000000000..cd89fe9b2 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/gate-round3/test-task.log @@ -0,0 +1,32 @@ +OK valid task validates (exit 0) +OK unknown task key exits 2 (exit 2) +OK unsupported taskVersion exits 2 (exit 2) +OK invalid task id exits 2 (exit 2) +OK empty prompt exits 2 (exit 2) +OK NUL in expectExact exits 2 (exit 2) +OK out-of-range timeout exits 2 (exit 2) +OK missing mission file exits 4 (exit 4) +OK task with valid mission validates (exit 0) +OK invalid mission exits 2 (exit 2) +OK validate missing task exits 4 (exit 4) +OK validation does not modify the task file +OK prune dry-run exits 0 (exit 0) +OK dry-run deleted nothing +OK prune --keep=2 --yes removes oldest (exit 0) +OK kept exactly 2 newest runs +OK newest run kept, oldest pruned +OK append-only receipt written (3 entries) +OK sessions/workspaces untouched by prune +OK prune with invalid keep exits 4 (exit 4) +skip adapter seam cases (docker daemon unavailable) +skip workspace/capability cases (docker daemon unavailable) +skip live task cases (docker unavailable) +OK onboard without name exits 4 (non-interactive) (exit 4) +OK onboard --name renders profile (exit 0) +OK profile written +OK canon structure: required filled, optional placeholdered +OK canon sections present +skip live recall pair: user recall run, recalled user name (docker daemon unavailable) +OK no agent identity on headless run + +selftest: 26 passed, 0 failed diff --git a/agents/darkwing/work/queue-55/out/mutants-round3/round3.txt b/agents/darkwing/work/queue-55/out/mutants-round3/round3.txt new file mode 100644 index 000000000..0b16ee59b --- /dev/null +++ b/agents/darkwing/work/queue-55/out/mutants-round3/round3.txt @@ -0,0 +1,52 @@ +resolve-ask-skipped-when-note: killed by decide with --note still asks; a No sends nothing ( +unknown-space-only: killed by outcome unknown: the CLI's sentence, no retry, then the tr +restore-no-altleave: killed by runApp leaves the terminal as it found it on q, end of inp +restore-no-raw-off: killed by runApp leaves the terminal as it found it on q, end of inp +no-sigterm: killed by runApp leaves the terminal as it found it on q, end of inp +no-sigint: killed by runApp leaves the terminal as it found it on q, end of inp +no-sighup: killed by runApp leaves the terminal as it found it on q, end of inp +resize-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp +paint-no-reverse: killed by NO_COLOR: no escape codes, except reverse video on the sel +cells-no-cjk: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +cells-no-emoji-presentation: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F | runLoop never overlaps ticks and stops after the one in fl +cells-no-fe0f: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +b1-cut-keeps-end: killed by a long question is cut inside the question; the action, ta | a decide box too big for the terminal says so, and every k +app-host-not-live: killed by the app's business comes from a live bus host only ( +quit-no-answer: killed by quitting at the question answers No and sends nothing, arm +refresh-in-overlay: killed by a timer tick reads nothing under an overlay, never answers +refresh-answers: killed by a timer tick reads nothing under an overlay, never answers +refresh-drops-resolved: killed by a timer tick reads nothing under an overlay, never answers +unknown-esc-stays: killed by outcome unknown: the CLI's sentence, no retry, then the tr +trail-task-id: killed by keys: views, moving, trail of a selection, the command lin +business-keeps-data: killed by keys: views, moving, trail of a selection, the command lin +nav-no-brackets: killed by every screen and overlay fits 120x36, 80x24 and the narrow +typeahead-kept: killed by type-ahead never answers the question: a paste, keys typed +no-arm-delay: killed by type-ahead never answers the question: a paste, keys typed +other-key-waits: killed by any key but y or Y at the question answers No; nothing is +no-capital-y: killed by 1-9 shows the decide flow's own lines and question; only y +command-types-names: killed by keys: views, moving, trail of a selection, the command lin +press-1-1: killed by one option: the status line says Press 1, not 1-1 ( +tiny-no-guard: killed by every screen and overlay fits 120x36, 80x24 and the narrow | below 40x10 the question is hidden, so every key answers i +r3-y-ignores-shown: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k +r3-shown-is-min-size: killed by a decide box too big for the terminal says so, and every k +r3-shown-at-arm: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k +r3-no-question-whole-box: killed by every screen and overlay fits 120x36, 80x24 and the narrow +r3-keep-zero: killed by a decide box too big for the terminal says so, and every k | at every size the decide box shows each line y depends on, +r3-hidden-no-any-key: killed by every screen and overlay fits 120x36, 80x24 and the narrow | a decide box too big for the terminal says so, and every k +r3-start-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp +r3-text-never-asking: killed by below 40x10 the question is hidden, so every key answers i +r3-text-always-asking: killed by below 40x10 the question is hidden, so every key answers i +r3-answer-render-unguarded: killed by a throw while answering the question goes to crash, sends +r3-answer-throw-keeps-yes: killed by a throw while answering the question goes to crash, sends +r3-wrap-code-points: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-2329: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-232a: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-a960: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-fe10: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-fe50: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-16fe0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-17000: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-1aff0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-1aff0-short: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +r3-cells-no-1f200: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F +survived 0 diff --git a/agents/darkwing/work/queue-55/out/mutants-round3/run3.mjs b/agents/darkwing/work/queue-55/out/mutants-round3/run3.mjs new file mode 100644 index 000000000..89c8f76fa --- /dev/null +++ b/agents/darkwing/work/queue-55/out/mutants-round3/run3.mjs @@ -0,0 +1,82 @@ +// Round 3 mutants: round 2's 30 less overlay-cut-loses-prompt (its line is +// gone), four re-anchored to lines round 3 changed, then round 3's own, +// Dewey's B1 among them. Each edits the source, runs +// the cli suite, restores. +import { readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +const CLI = process.argv[2]; +const M = [ + ["resolve-ask-skipped-when-note", "cli.mjs", "if (ask && !(await ask(", "if (ask && note === undefined && !(await ask("], + ["unknown-space-only", "app.mjs", "/^outcome[ -]unknown/", "/^outcome unknown/"], + ["restore-no-altleave", "app.mjs", 'stdout.write("\\x1b[?25h\\x1b[?1049l");', ""], + ["restore-no-raw-off", "app.mjs", "stdin.setRawMode?.(false);", ""], + ["no-sigterm", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGINT", "SIGHUP"];'], + ["no-sigint", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGHUP"];'], + ["no-sighup", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGINT"];'], + ["resize-unguarded", "app.mjs", "const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));", "const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);"], + ["paint-no-reverse", "screens.mjs", "return l.sel ? `\\x1b[7m${body}\\x1b[27m` : body;", "return body;"], + ["cells-no-cjk", "screens.mjs", "(n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||", ""], + ["cells-no-emoji-presentation", "screens.mjs", "/\\p{Emoji_Presentation}/u.test(c) ||", ""], + ["cells-no-fe0f", "screens.mjs", "if (n === 0xfe0f) return prev && /\\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;", ""], + ["b1-cut-keeps-end", "screens.mjs", "return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine(\"question\", w - 4), ...tail] : null;", "return b;"], + ["app-host-not-live", "cli.mjs", "const business = state?.live ? state.business : null;", "const business = state?.business ?? null;"], + ["quit-no-answer", "app.mjs", " this.cancelAsk?.();\n this.wake?.();", " this.answer?.(\"y\");\n this.wake?.();"], + ["refresh-in-overlay", "app.mjs", "s.overlay || s.command !== null ? null : this.#read", "this.#read"], + ["refresh-answers", "app.mjs", 'if (k !== "refresh") this.answer(k);', "this.answer(k);"], + ["refresh-drops-resolved", "app.mjs", 'this.#read(s.view, s.statusStyle === "success")', "this.#read(s.view)"], + ["unknown-esc-stays", "app.mjs", ' if (k === "esc") this.state.overlay = null;', ""], + ["trail-task-id", "app.mjs", 'view === "tasks" ? item.task_ref : view === "trail"', 'view === "tasks" ? item.title : view === "trail"'], + ["business-keeps-data", "app.mjs", " this.state.data = {};", ""], + ["nav-no-brackets", "screens.mjs", "piece(`[${label}]`, \"bold\")", "piece(` ${label} `, \"bold\")"], + ["typeahead-kept", "app.mjs", " this.keys = [];\n this.answer", " this.answer"], + ["no-arm-delay", "app.mjs", " }, this.askMs);", " }, 0);"], + ["other-key-waits", "app.mjs", 'this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));', 'this.answer = (k) => (k === "y" || k === "Y" ? answer(decideShown(this.state.overlay, this.cols, this.rows)) : ["n", "N", "esc", "enter", "q"].includes(k) ? answer(false) : null);'], + ["no-capital-y", "app.mjs", '(k === "y" || k === "Y") && decideShown', '(k === "y") && decideShown'], + ["command-types-names", "app.mjs", '[...k].length === 1 && k >= " "', 'k >= " "'], + ["press-1-1", "app.mjs", 'n === 1 ? "1" : `1-${n}`', "`1-${n}`"], + ["tiny-no-guard", "screens.mjs", "if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking));", ""], + // Round 3. + ["r3-y-ignores-shown", "app.mjs", '(k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows)', '(k === "y" || k === "Y")'], + ["r3-shown-is-min-size", "app.mjs", "&& decideShown(this.state.overlay, this.cols, this.rows))", "&& this.cols >= 40 && this.rows >= 10)"], + ["r3-shown-at-arm", "app.mjs", 'this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));', 'const shown = decideShown(this.state.overlay, this.cols, this.rows);\n this.answer = (k) => answer((k === "y" || k === "Y") && shown);'], + ["r3-no-question-whole-box", "screens.mjs", "if (q < 0 || end < 0) return null;", "if (q < 0 || end < 0) return b;"], + ["r3-keep-zero", "screens.mjs", "return keep >= 1 ?", "return keep >= 0 ?"], + ["r3-hidden-no-any-key", "screens.mjs", 'o.asking ? "Any key answers No." : ', ""], + ["r3-start-unguarded", "app.mjs", "guard(() => void app.start());", "void app.start();"], + ["r3-text-never-asking", "screens.mjs", "Boolean(state.overlay?.asking)", "false"], + ["r3-text-always-asking", "screens.mjs", "const keys = asking ?", "const keys = true ?"], + ["r3-answer-render-unguarded", "app.mjs", "try {\n this.render();\n } catch (e) {\n // The pump sends this to crash; the decide flow ends with No.\n resolve(false);\n throw e;\n }", "this.render();"], + ["r3-answer-throw-keeps-yes", "app.mjs", "resolve(false);\n throw e;", "resolve(yes);\n throw e;"], + ["r3-wrap-code-points", "screens.mjs", "for (const { segment: c } of graphemes.segment(word))", "for (const c of word)"], + ["r3-cells-no-2329", "screens.mjs", " n === 0x2329 ||\n", ""], + ["r3-cells-no-232a", "screens.mjs", " n === 0x232a ||\n", ""], + ["r3-cells-no-a960", "screens.mjs", " (n >= 0xa960 && n <= 0xa97c) ||\n", ""], + ["r3-cells-no-fe10", "screens.mjs", " (n >= 0xfe10 && n <= 0xfe19) ||\n", ""], + ["r3-cells-no-fe50", "screens.mjs", " (n >= 0xfe50 && n <= 0xfe6b) ||\n", ""], + ["r3-cells-no-16fe0", "screens.mjs", " (n >= 0x16fe0 && n <= 0x16ff1) ||\n", ""], + ["r3-cells-no-17000", "screens.mjs", " (n >= 0x17000 && n <= 0x18d08) ||\n", ""], + ["r3-cells-no-1aff0", "screens.mjs", " (n >= 0x1aff0 && n <= 0x1b2fb) ||\n", ""], + ["r3-cells-1aff0-short", "screens.mjs", "(n >= 0x1aff0 && n <= 0x1b2fb)", "(n >= 0x1aff0 && n <= 0x1b2fa)"], + ["r3-cells-no-1f200", "screens.mjs", " (n >= 0x1f200 && n <= 0x1f2ff) ||\n", ""], +]; +const only = process.argv.slice(3); +let survived = 0; +for (const [name, file, from, to] of M) { + if (only.length && !only.includes(name)) continue; + const path = join(CLI, "src", file); + const orig = readFileSync(path, "utf8"); + const n = orig.split(from).length - 1; + if (n !== 1) { console.log(`${name}: SKIP, found ${n} times`); continue; } + try { + writeFileSync(path, orig.replace(from, to)); + const r = spawnSync(process.execPath, ["--test", "--test-timeout=20000", "tests/"], { cwd: CLI, encoding: "utf8", timeout: 180000 }); + const fails = (r.stdout.match(/^✖ (.*?) \(/gm) ?? []).slice(0, 2).map((s) => s.slice(2, 60)); + const killed = r.status !== 0; + if (!killed) survived++; + console.log(`${name}: ${killed ? "killed" : "SURVIVED"}${killed ? ` by ${[...new Set(fails)].join(" | ")}` : ""}`); + } finally { + writeFileSync(path, orig); + } +} +console.log(`survived ${survived}`); diff --git a/agents/darkwing/work/queue-55/out/probe-round3.mjs b/agents/darkwing/work/queue-55/out/probe-round3.mjs new file mode 100644 index 000000000..4268d42a5 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/probe-round3.mjs @@ -0,0 +1,25 @@ +import { test } from "node:test"; +// The smallest row count that shows a gated decision with a task, per width, for a short and a long +// question. Run: CLI=/packages/cli node --test probe-round3.mjs +const CLI = process.env.CLI; +const { App } = await import(`${CLI}/src/app.mjs`); +const { decideShown } = await import(`${CLI}/src/screens.mjs`); +const { broker } = await import(`${CLI}/tests/helpers.mjs`); +test("probe", async (t) => { + for (const long of [false, true]) { + const bus = broker(t); + const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?"; + bus.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question }); + const a = new App({ business: "demo", transport: (b) => bus.transport(b), cols: 120, rows: 40, refreshMs: 0, askMs: 0 }); + await a.start(); await a.input("1"); + const o = a.state.overlay; + const out = []; + for (const cols of [40, 50, 60, 80, 120]) { + let min = null; + for (let rows = 10; rows <= 40; rows++) if (decideShown(o, cols, rows)) { min = rows; break; } + out.push(`${cols}:${min}`); + } + console.log(long ? "long" : "short", out.join(" ")); + a.quit("q"); + } +}); diff --git a/agents/darkwing/work/queue-55/out/probe-round3.txt b/agents/darkwing/work/queue-55/out/probe-round3.txt new file mode 100644 index 000000000..f6c56e9f9 --- /dev/null +++ b/agents/darkwing/work/queue-55/out/probe-round3.txt @@ -0,0 +1,11 @@ +short 40:29 50:26 60:22 80:20 120:20 +long 40:30 50:27 60:23 80:21 120:21 +✔ probe (843.280351ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 1 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 974.31265