docs(cli): row 45 S4 follow-up round 2 candidate packet (rocko)

build.patch c8cec070…6756, candidate-manifest 5b067a9d…0e0e, base d539d8d2.
Covers Darkwing 26872, Filbert 26880 and lead decision 73.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 09:10:55 -05:00
co-authored by Claude Opus 5.5
parent d539d8d2cc
commit af1377d7e8
66 changed files with 10770 additions and 180 deletions
+224 -105
View File
@@ -1,35 +1,222 @@
# Row 45 (#1527): S4 follow-up, candidate packet
# Row 45 (#1527): S4 follow-up, candidate packet, round 2
Author: Rocko. Reviewers: Filbert and Darkwing. Brief:
`docs/plans/2026-10-09_s4-follow-up-and-cohort.md`, section "S4 follow-up".
Rulings: lead decision 72. Base: `521597bb` (`base.txt`). This packet is
Rulings: lead decision 72, and lead decision 73 with comment 26879 for
round 2. Base: `d539d8d2` (`base.txt`). Round 1 was over `521597bb`. None
of the 8 files changed upstream between the two bases. This packet is
uncommitted. There are no commits, pushes or Gitea calls, and no token or
private binding was read.
Round 2 answers Darkwing's changes verdict (comment 26872, rev 208) and
Filbert's (comment 26880, rev 211). The round 1 text follows the round 2
section and still holds, except where round 2 says otherwise.
## Files (`files.txt`, 8)
| File | Change |
|---|---|
| `packages/cli/src/notifier.mjs` | F2 refusal limit, J4 type check, directory and file error messages |
| `packages/cli/src/host.mjs` | `broker.connected` guards on both `close` sends (old :144 and :150) |
| `packages/cli/README.md` | digest mark, definite-refusal bullet, type-check list, refusal wording |
| `packages/cli/src/notifier.mjs` | F2 refusal limit, J4 type check, error messages; round 2: the 30-minute refusal wait from the journal, a real-date `day` check, three more error paths |
| `packages/cli/src/host.mjs` | `broker.connected` guards on both start-failure `close` sends; round 2: a callback on all four sends (:144, :150, :184, :188) |
| `packages/cli/README.md` | digest mark, definite-refusal bullet, type-check list, refusal wording; round 2: the refusal wait, manual recovery, the digest-mark sentence, the new error paths |
| `scripts/bus-service.sh` | install message gains the `mkdir -m 0700 -p` line |
| `packages/cli/tests/notifier.test.mjs` | F2, J4, N2, N4 and error-path tests |
| `packages/cli/tests/host.test.mjs` | reader-cap exposure, M28, N5, both broker-death guards, `t.after` in "a notifier that dies" |
| `packages/cli/tests/notifier.test.mjs` | F2, J4, N2, N4 and error-path tests; round 2: spacing, restart, X9, day validity, note 1 paths |
| `packages/cli/tests/host.test.mjs` | reader-cap exposure, M28, N5, both broker-death guards, `t.after`; round 2: three EPIPE tests, the X14 assertion |
| `packages/cli/tests/trackers-boot.test.mjs` | new: Sage's trackers boot test, byte-identical copy |
| `packages/discord/tests/journal.test.mjs` | `deadPid()` replaces the fixed pid `2 ** 22 - 7` |
`build.patch`: `git diff --cached --binary 521597bb` over those files, +459/−44.
It applies cleanly to 521597bb (see the gate tree's `out/manifest-check-tree.txt`).
`build.patch`: `git diff --cached --binary d539d8d2` over those files,
+635/−56. It applies cleanly to d539d8d2 (`out/manifest-check-tree-r2.txt`).
## What changed
## Round 2
### R1: a callback on every send that can race a death (both reviews)
`host.mjs` :144, :150 and :188 now read
`if (broker.connected) broker.send({ op: "close" }, () => {});`, and :184
reads `if (notify.connected) notify.send({ op: "stop" }, () => {});`.
`connected` stays true after a child dies until Node processes the
disconnect. A send in that window fails with EPIPE. Without a callback,
Node emits that as an `error` event on the next tick. The `once(child,
"exit")` in `ended()` rejects on `error`, so the raw EPIPE replaces the
real outcome. With a callback, the error goes to the callback and nothing
else sees it. The `exit` still settles `ended()`.
What the raw EPIPE cost (Filbert's point):
- **:150**, the refusal path. A notifier refusal is a CliError with exit
3, which `RestartPreventExitStatus=2 3 4` keeps down. A raw EPIPE is not
a CliError, so `cli.mjs` exits 1 with a stack trace, and the unit
restarts the host every 15 s against a refusal that won't go away.
- **:144**, the no-reply path. The exit code is 1 either way, but the
operator gets the EPIPE instead of the notifier's message.
- **:184 and :188**, in `close()`. A raw EPIPE rejects `close()`, so the
host never removes its state file or finishes `done`.
Tests (Darkwing's deterministic form). `failSends(t, fake)` in
`host.test.mjs` traps the `send` setter of each new child, as the
reader-cap spy does. When `fake(child, m)` returns an error, the send
fails the way Node fails a write to a dead child: the error goes to the
callback if there is one, or else to an `error` event on the next tick.
`epipe(child)` SIGKILLs the child and returns an EPIPE error. The real
window is about a millisecond wide, so a test that waits for it would be
flaky. This one is not.
- A close send that fails with EPIPE after the notifier refuses: the host
rejects with exit 3 and `notifier refused to start: … no dmRecipient`.
- A close send that fails with EPIPE after the notifier dies without a
reply: the host rejects with exit 1 and `notifier exited (null) before it
replied`.
- `close()` whose stop and close sends both fail with EPIPE resolves with
1 and removes the state file.
All three passed 15 of 15 runs. Without a callback, each one fails (G144cb,
G150cb, G184 and G188 in the mutant table).
A correction from my first round 2 mutant run: G184 hung the host test file
for its 900 s timeout instead of failing. The `close()` test had
`t.after(() => host.close(0))`. When the mutant made `close()` reject,
`closing` was already set, so the cleanup's second `close()` waited on a
`done` that never settles. The broker it never closed also kept the file
alive. The fix: `failSends` now SIGKILLs every child it saw after each
test, and that test has no `t.after` close. With the fix, G184 and G188 each
fail that test in about 20 s. I stopped the first run, restored the mutated
`host.mjs` from its `.orig` copy (the manifest checked OK), then rebuilt the
patch and re-ran the whole gate and every mutant from scratch.
### R2 / B1: a definite refusal waits the full 30 minutes (decision 73)
In round 1, a refusal backed off the same way as an unknown outcome: 30 s,
doubling. So five refusals took 7.5 minutes, and a binding typo gave up
every blocking DM before anyone could fix it. Now:
- After a definite refusal, the next attempt waits `BACKOFF_MAX_MS`
(30 min). The log line reads `refused (HTTP 403); retry in 1800 s`.
- The wait is counted from the refusal's `at` in the journal. `openJournal`
keeps `refusedAt`, the time of each decision's last definite refusal, and
updates it on every append. `tick` skips a decision until 30 minutes after
that time. A restart rebuilds the map from the file, so it can't shorten
the wait.
- Unknown outcomes keep the doubling backoff from 30 s, with no limit.
The five sends therefore go at 0, 30, 60, 90 and 120 minutes, and the DM
gives up at 2 hours.
Tests:
- **Spacing.** Polled every `POLL_MS` on a fake clock against a permanent
403: sends at 0, 30, 60, 90 and 120 min, no gave-up line before 120 min,
one at 120 min, and four `retry in 1800 s` log lines.
- **Restart.** Refusals at 0 and 30 min, then a restart at 31 min. The next
send comes at 60 min, not at once.
- The existing backoff test now checks both waits. An unknown outcome logs
`retry in 30 s`. A 403 sends nothing at +29 min, sends at +30 min, and
logs `retry in 1800 s`.
### README
- **Recovery after a give-up is manual.** Fixing the binding does not
resend the DM, which stays given up. The decision stays open in `mosaic
inbox`, the digest lists it, and the operator decides it with `mosaic
decide`.
- The definite-refusal bullet gives the 30-minute wait from the journal and
the 2-hour span. The `unknown` bullet keeps the doubling backoff.
- Darkwing's note 4: the digest-mark sentence now quotes its three states,
and the long line is rewrapped.
- The `day` field and the refusal list name the round 2 checks.
### Optional items taken
- **X9 (Filbert):** a 0400 `sent.jsonl` in a good directory refuses with
`notify journal is not writable (EACCES): <file>`.
- **X14 (Filbert) and Darkwing's note 2:** the bus-service install test
asserts the `mkdir -m 0700 -p <dataRoot>/notify/<business>` line.
- **Darkwing's note 3:** a `day` must be a real date. `2026-13-45` and
`2026-02-30` now refuse as `(day)`. The check is the `YYYY-MM-DD` shape,
then `Date.parse`, then a `toISOString()` round trip.
- **Darkwing's note 1:** the three raw-error paths now refuse with a
CliError and exit 3:
- A dangling symlink in place of the directory: `mkdir` fails with
ENOENT, and an `lstat` finds a link, so the message is `notify journal
directory must not be a symlink`.
- A parent that is a file: ENOTDIR gives `notify journal directory
cannot be created (ENOTDIR)`.
- A `sent.jsonl` that is a directory: EISDIR gives `notify journal must
be a regular file, mode 0600, owned by this user`.
### Declined: a type check inside `append` (Filbert's note 1)
Filbert left this to me, and I left it out. A failing check in `append`
would throw inside `attempt`'s catch, which journals and logs the send as
`unknown`. The DM was in fact sent. The next tick would send it again, and
then again on every later tick, so a bad `messageId` would become a loop of
duplicate DMs. Today the writer can't produce a bad line: `rest.mjs`
`createMessage` throws `unknown` on a 2xx without a string id. If that
changes, the open-time check refuses the bad line with exit 3. That is a
loud failure, and it fits fail-closed better than a send loop.
Darkwing's note 5 (`accessSync` is advisory) needs no change. `open` still
refuses, and only the message could differ.
### Round 2 gate
The gate tree `/mnt/storage/scratch/rocko-r45/tree` is now a detached
worktree at d539d8d2 with `build.patch` applied. The manifest checks 8/8 OK
(`out/manifest-check-tree-r2.txt`). `run.sh` ran every node suite and then
every `test-*.sh`, one at a time, with Docker withheld from test-task and
test-release. The outputs are `out/node-*-r2.txt` and `out/suite-*-r2.txt`.
It ran 14:02:34Z to 14:05:56Z, and load average was 1.01 at the start and
2.44 at the end.
| Suite | Pass / fail |
|---|---|
| node business / bus / cli / control-board | 60/0 · 67/0 · 66/0 · 124/0 |
| node conversation / discord / ledger / mosaic | 152/0 · 178/0 · 78/0 · 69/0 |
| node queue / seat / tasks / webui | 148/0 · 19/0 · 51/0 · 14/0 |
| test-auth / conductor / config / discord | 15/0 · 17/0 · 24/0 · 66/0 |
| test-extension-package / foundation / queue / release | 18/0 · 44/0 · 27/0 · 4/0 (Docker cases skipped) |
| test-task | 26/2: "user recall run succeeds", "recalled user name" |
Conversation passes in full now that row 46 has landed (2d308abd). The two
test-task failures are the live-recall cases that need Docker. They failed
the same way on the round 1 base. I did not rerun the base on d539d8d2,
because none of the 8 files changed between the two bases.
### Round 2 mutants
`mutants.sh` re-ran all 18 round 1 mutants and 13 new ones in the gate
tree after the gate (`out/r2/mutants.txt`, `out/r2/mut-*.txt`). It ran
14:05:56Z to 14:08:50Z, and load average was 2.44 at the start and 3.85 at
the end. All **31 of 31** were killed, each by a failing test, with 0
cancelled. The round 1 mutants were killed by the same tests as in round 1.
F2a and F2f are now also killed by the spacing test. Afterwards the tree
checked 8/8 OK (`out/r2/manifest-check-mut.txt`).
| Id | Mutation | Killed by |
|---|---|---|
| G144cb | no callback on the :144 close send | EPIPE after the notifier dies unanswered |
| G150cb | no callback on the :150 close send | EPIPE after the notifier refuses |
| G184 | no callback on the `close()` stop send | `close()` with EPIPE on stop and close |
| G188 | no callback on the `close()` close send | `close()` with EPIPE on stop and close |
| R2a | a definite refusal backs off like an unknown | backoff test (the 1800 s log); spacing test |
| R2b | `tick` ignores `refusedAt` | restart after the second refusal |
| R2c | `refusedAt` never set | restart after the second refusal |
| X9 | no CliError for an unwritable `sent.jsonl` | the unwritable-journal test |
| X14 | install message drops the `mkdir -m 0700` line | bus-service.sh install test |
| D3 | `day` is a shape check only | wrong-type table (`2026-13-45`, `2026-02-30`) |
| N1a | no symlink message for a dangling directory link | dangling link, file parent, directory journal |
| N1b | ENOTDIR not turned into a CliError | the same test |
| N1c | EISDIR not turned into a CliError | the same test |
## Round 1 (still holds unless round 2 says otherwise)
### F2: the refusal limit (decision 72)
- `REFUSAL_LIMIT = 5`, exported. A definite refusal is a `dm` journal line
with outcome `refused` and an integer status from 400 to 499 other than
429. A 429, an `unknown` outcome and a refusal without a status never
count. Unknown outcomes keep retrying at the 30-minute cap, with no limit.
count. Unknown outcomes keep retrying with no limit.
- The count comes from the journal: `openJournal` keeps a `refusals` map
and a `gaveUp` set and updates both on every append. A restart rebuilds
them from the file.
@@ -55,22 +242,22 @@ or the first field that fails:
- `at`: not an ISO string that round-trips through `toISOString()`.
- `kind`: not `dm` or `digest`.
- `decision`: for a dm, a non-empty string; for a digest, null or absent.
- `day`: required and `YYYY-MM-DD` for a digest; optional for a dm, but
well-formed if present.
- `day`: required and a real `YYYY-MM-DD` date for a digest (round 2);
optional for a dm, but valid if present.
- `outcome`: one of confirmed, refused, unknown or gave-up. gave-up is
allowed only on a dm.
- `messageId`: a string on `confirmed`; null or a string otherwise.
- `status`: an integer when present.
Choices a reviewer may contest: a digest with a decision is refused,
gave-up is dm-only, and `confirmed` requires a string messageId. The test
table also proves that the good fixture lines still load.
Both reviewers agreed with the contested choices: every line is checked, a
digest with a decision is refused, gave-up is dm-only, and `confirmed`
requires a string messageId.
### Error messages (the brief's error items)
- mkdir fails with EACCES, EPERM or EROFS:
- mkdir fails with EACCES, EPERM or EROFS (round 2: or ENOTDIR):
`notify journal directory cannot be created (CODE): <dir>`.
- Directory is a symlink (checked with lstat):
- Directory is a symlink (checked with lstat; round 2: a dangling one too):
`notify journal directory must not be a symlink: <dir>`. The 0700 check
follows it as before.
- Directory not writable (`accessSync` W_OK|X_OK):
@@ -85,19 +272,11 @@ already states the 0700 rule.
### host.mjs guards
Both `broker.send({ op: "close" })` sends on the notifier start-failure
paths are now `if (broker.connected) …`. :144 is the no-reply path (a start
paths are `if (broker.connected) …`. :144 is the no-reply path (a start
timeout, or the notifier exiting before it replies). :150 is the refusal
path. On a closed channel, `ChildProcess.send` emits `error`
(ERR_IPC_CHANNEL_CLOSED).
There is a remaining window, which I'm recording but did not change.
`connected` stays true after the broker dies until Node processes the
disconnect. If the broker is SIGKILLed and the notifier exits a moment
later, the :144 close can still be sent. When I probed it, the late send
emitted no `error` event in 5 of 5 runs and `connected` was false shortly
afterwards. Passing a callback to that `send` would close the window
completely. I left it out because the brief asked for the guard and no
failure was observed. It is a candidate follow-up if a reviewer wants it.
(ERR_IPC_CHANNEL_CLOSED). Round 1 recorded the remaining window after a
SIGKILL and left it open. Round 2 closes it (R1 above).
### Tests
@@ -115,93 +294,33 @@ failure was observed. It is a candidate follow-up if a reviewer wants it.
swapped for a symlink fails with ELOOP and writes nothing through it.
- **Guard (G150):** the spy SIGKILLs the broker synchronously on the start
send. The notifier still refuses with `/no dmRecipient/`, there is no
`error` event and there is no `close` send. 15/15 runs passed.
`error` event and there is no `close` send.
- **Guard (G144):** the spy SIGSTOPs the notifier, SIGKILLs the broker,
and SIGKILLs the notifier once the broker's `disconnect` fires. The host
rejects with `notifier exited (null) before it replied`, exit 1, with no
`error` event and no `close` send. 15/15 runs passed, and 10/10 after a
small cleanup. My first version killed both children at once, and the
test caught the window described above.
`error` event and no `close` send.
- **`t.after`** is added to "a notifier that dies".
- **0500 directory and 0500 parent:** modes are restored in try/finally,
not `t.after`. A `t.after` restore runs after `tmp()` cleanup and leaked a
directory once; I removed that directory. Both tests skip when running as
root.
not `t.after`, because a `t.after` restore runs after `tmp()` cleanup.
Both tests skip when running as root.
- **Discord lock test:** `deadPid()` spawns a node child, reaps it, and
loops until `!pidAlive(pid)`. pid_max here is 4194304, so `2 ** 22 - 7`
can be a live pid. I replaced all five uses in that file, not only :120.
can be a live pid. All five uses in that file are replaced.
- **Trackers boot test:** copied byte-identical to
`packages/cli/tests/trackers-boot.test.mjs`. No import changes were needed.
Sage's tracked original in `agents/sage/work/s4-follow-up/` is left for
Sage to remove, because I don't edit another seat's directory.
`packages/cli/tests/trackers-boot.test.mjs`. Sage's tracked original in
`agents/sage/work/s4-follow-up/` is left for Sage to remove at landing.
## Gate
### Round 1 gate and mutants
Round 1 ran the full gate. I then added the G144 test, which changes only
`host.test.mjs`. I rebuilt the patch, re-applied it to a clean checkout of
the tree (manifest OK), and re-ran the cli node suite: **60/0**
(`out/node-cli.txt`). The other node suites and the `test-*.sh` suites below
are from round 1, on the identical source files.
Detached worktree `/mnt/storage/scratch/rocko-r45/tree` at 521597bb with
`build.patch` applied; manifest check OK (`out/manifest-check-tree.txt`).
`run.sh` ran every `packages/*/tests` suite, then every `scripts/test-*.sh`,
in sequence. test-task and test-release ran with
`DOCKER_HOST=unix:///nonexistent-rocko-r45-docker.sock`. Load average was
4.34 at the start and 5.65 at the end.
| Suite | Pass / fail |
|---|---|
| node business / bus / cli / control-board | 60/0 · 67/0 · 60/0 (round 2; 59/0 in round 1) · 124/0 |
| node discord / ledger / mosaic / queue | 178/0 · 78/0 · 69/0 · 148/0 |
| node seat / tasks / webui | 19/0 · 51/0 · 14/0 |
| node conversation | 149/3: K1, K3, K10 (row 46, expected) |
| test-auth / conductor / config / discord | 15/0 · 17/0 · 24/0 · 66/0 |
| test-extension-package / foundation / queue / release | 18/0 · 44/0 · 27/0 · 4/0 |
| test-task | 26/2: "user recall run succeeds", "recalled user name" |
Unpatched base, worktree `/mnt/storage/scratch/rocko-r45/base` at 521597bb
(`out/base-*.txt`):
- test-task: 26/2, the same two live-recall failures. They need Docker, which
the gate withholds. They are not caused by this patch.
- conversation: 150/2 (K1, K3). The patch doesn't touch
`packages/conversation`, so K10 is intermittent. It is one of the brief's
row 46 exceptions.
## Mutants (`mutants.sh`, `out/mutants.txt`, `out/mut-*.txt`)
Each mutant is one perl substitution in the gate tree, followed by a run of
the cli and discord node suites. Only a `fail` counts as a kill. Every
failing test is named, and none is a flaky bystander. The table comes from
the round 2 run, where all 18 were killed. In round 1, before its test
existed, G144 survived. After the run the tree re-checked OK
(`out/manifest-check-mut.txt`). Load average was 3.98 at the start and 5.38
at the end.
| Id | Mutation | Result | Killed by |
|---|---|---|---|
| N2 | `lstatSync(dir)` → `statSync(dir)` | killed | symlinked directory refuses |
| N4 | drop `O_NOFOLLOW` from the append open | killed | append after symlink swap |
| N5 | death check ignores `signalCode` | killed | watchChildren, child died before call |
| M28 | drop the `prior?.live` refusal | killed | second host refuses with exit 3 |
| G150 | drop the guard on the refusal-path close | killed | notifier refuses after broker died |
| G144 | drop the guard on the no-reply-path close | killed | notifier dies before it replies |
| F2a | limit 5 → 6 | killed | five definite refusals stop a DM |
| F2b | 429 counts as definite | killed | 429s/unknowns never count |
| F2c | tick ignores `gaveUp` | killed (2) | crash recovery; five refusals |
| F2d | no crash-recovery give-up in tick | killed | crash recovery |
| F2e | no digest mark | killed | five refusals (digest check) |
| F2f | no give-up after the fifth refusal | killed | five refusals |
| J4a | `at` round-trip check removed | killed | wrong-type table |
| J4b | dm decision type check removed | killed | wrong-type table |
| J4c | `status` integer check removed | killed | wrong-type table |
| J4d | confirmed messageId check removed | killed | wrong-type table |
| E1 | `accessSync` writability check removed | killed | unwritable directory/parent |
| E2 | symlink refusal removed | killed | symlinked directory refuses |
Round 1, over 521597bb, was green except for conversation K1, K3 and K10
(row 46) and test-task's two live-recall failures, which need Docker and
fail the same way on the base. All 18 round 1 mutants were killed. The
files are `out/node-*.txt`, `out/suite-*.txt`, `out/base-*.txt` and
`out/mut-*.txt` without the `-r2` suffix. Both reviewers reproduced them.
## Hashes
- `build.patch`: `4ed9ff61b94b4c9d2426e5703c2e6c0dbcd6bc0c64a88d59347a8b47f4933408`
- `candidate-manifest.sha256`: `b329fdcbdf8cb62659f239359570dfa9771559e3112b743df55429244769ed14`
- `build.patch`: `c8cec070da60d8297f1ee5b006a1099ab9376fd3abf32fa4461f967750ad6756`
- `candidate-manifest.sha256`: `5b067a9dad645c31d99e1ea02575cd2fc1ba547ce011ea81c56b17ae29da0d0e`
- `packet-manifest.sha256`: a sorted sha256 list of every packet file except itself.
- Round 1, for reference: `build.patch` `4ed9ff61…3408`, `candidate-manifest.sha256` `b329fdcb…ed14`.
+1 -1
View File
@@ -1 +1 @@
521597bbe0cf51a0a1aa3b7a16f13fc7b59c98c6
d539d8d2cc930ff8707f8d778a78b1fe661a3d99
+276 -63
View File
@@ -1,28 +1,44 @@
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 589078c3..67dc6618 100644
index 589078c3..0eb1423c 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -145,8 +145,8 @@ inbox through the reader capability and does two things:
@@ -145,9 +145,10 @@ inbox through the reader capability and does two things:
- **Digest.** It sends one digest a day at 08:00 America/Chicago. The hour
comes from the IANA zone, so daylight saving time is handled. If the host
starts after 08:00 and the day has no digest yet, the digest goes at once.
- The digest lists the inbox and marks each blocking decision as DM sent or
- DM pending. An empty inbox gets one line. Each message stays within
+ The digest lists the inbox and marks each blocking decision as DM sent,
+ DM pending, or DM refused, not retried. An empty inbox gets one line. Each message stays within
Discord's 2000 characters.
- Discord's 2000 characters.
+ The digest lists the inbox. It marks each blocking decision with one of
+ three states: "DM sent", "DM pending" or "DM refused, not retried". An
+ empty inbox gets one line. Each message stays within Discord's 2000
+ characters.
The notifier only reads the bus. Its memory is the journal
@@ -165,16 +165,29 @@ one line per send attempt:
`<dataRoot>/notify/<business>/sent.jsonl` (directory 0700, file 0600), with
@@ -160,21 +161,41 @@ one line per send attempt:
- A decision, or a day's digest, counts as sent once it has a `confirmed`
line.
-- A `refused` or `unknown` send is retried. The wait starts at 30 s and
- doubles up to 30 min. A duplicate costs less than a miss. Every retry
+- A `refused` or `unknown` send is retried. After an `unknown` outcome the
+ wait starts at 30 s and doubles up to 30 min. A duplicate costs less than
+ a miss. Every retry
carries the same Discord nonce, so a retry inside Discord's dedupe window
returns the first message. A DM's nonce comes from the decision id. A
digest's comes from the business and the day.
+- A definite refusal is a DM refused with an HTTP 4xx other than 429. After
+ 5 of them for one decision, the notifier appends one `gave-up` line, logs
+ it once and never sends that DM again; the digest marks the decision "DM
+ refused, not retried". The count comes from the journal, so a restart
+ keeps it. An `unknown` outcome (network, 5xx or 429) retries without a
+ limit (lead decision 72).
+- A definite refusal is a DM refused with an HTTP 4xx other than 429. The
+ next attempt waits the full 30 min, counted from the refusal's `at` in
+ the journal, so a restart does not shorten it. After 5 of them for one
+ decision, at least 2 hours apart end to end, the notifier appends one
+ `gave-up` line, logs it once and never sends that DM again. The digest
+ marks the decision "DM refused, not retried". The count comes from the
+ journal, so a restart keeps it. An `unknown` outcome (network, 5xx or
+ 429) retries without a limit (lead decisions 72 and 73).
+- Recovery after a give-up is manual. Fixing the binding does not resend
+ the DM, which stays given up. The decision stays open in `mosaic inbox`,
+ the digest lists it, and the operator decides it with `mosaic decide`.
- On open, a final line without its newline is a torn write. The notifier
copies those bytes to `torn-<UTC stamp>.bin` in the same directory (0600,
a new file, synced), then truncates `sent.jsonl` to its last newline and
@@ -35,20 +51,21 @@ index 589078c3..67dc6618 100644
- regular 0600 file you own.
+- A malformed complete line refuses with exit 3 and changes nothing. Each
+ line is type-checked: `at` an ISO timestamp, `kind` `dm` or `digest`,
+ `decision` a string (required for `dm`, null for `digest`), `day`
+ `YYYY-MM-DD` (required for `digest`), `outcome` one of `confirmed`,
+ `decision` a string (required for `dm`, null for `digest`), `day` a real
+ `YYYY-MM-DD` date (required for `digest`), `outcome` one of `confirmed`,
+ `refused`, `unknown` or `gave-up` (`gave-up` only for `dm`), `messageId`
+ a string (required when confirmed) or null, `status` an integer when
+ present.
+- The journal refuses with exit 3 when its directory is looser than 0700,
+ not yours, a symlink or not writable, when `sent.jsonl` is a symlink or
+ not writable, or when the file is not a regular 0600 file you own. The
+ message names the path.
+ not yours, a symlink (dangling or not) or not writable, or cannot be
+ created (for example, a parent is a file). It also refuses when
+ `sent.jsonl` is a symlink, a directory or not writable, or is not a
+ regular 0600 file you own. The message names the path.
- No Discord channel or user id goes in the journal, a log line or an
error.
diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs
index 159160c0..26553cba 100644
index 159160c0..083f8f1d 100644
--- a/packages/cli/src/host.mjs
+++ b/packages/cli/src/host.mjs
@@ -141,13 +141,13 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
@@ -56,22 +73,36 @@ index 159160c0..26553cba 100644
notify.kill("SIGTERM");
await ended(notify, 5000);
- broker.send({ op: "close" });
+ if (broker.connected) broker.send({ op: "close" });
+ if (broker.connected) broker.send({ op: "close" }, () => {});
await ended(broker, CLOSE_TIMEOUT_MS);
throw e;
}
if (ok?.ok !== true) {
await ended(notify, 5000);
- broker.send({ op: "close" });
+ if (broker.connected) broker.send({ op: "close" });
+ if (broker.connected) broker.send({ op: "close" }, () => {});
await ended(broker, CLOSE_TIMEOUT_MS);
throw new CliError(`notifier refused to start: ${typeof ok?.error === "string" ? ok.error : "notifier-refused"}`, 3);
}
@@ -181,11 +181,11 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
closing = true;
let result = code;
if (notify) {
- if (notify.connected) notify.send({ op: "stop" });
+ if (notify.connected) notify.send({ op: "stop" }, () => {});
if ((await ended(notify, CLOSE_TIMEOUT_MS)) !== 0 && result === 0) result = 1;
}
await queue;
- if (broker.connected) broker.send({ op: "close" });
+ if (broker.connected) broker.send({ op: "close" }, () => {});
if ((await ended(broker, CLOSE_TIMEOUT_MS)) !== 0 && result === 0) result = 1;
const now = readHostState(dataRoot);
if (now && now.pid === state.pid && now.startTime === state.startTime) rmSync(hostFile(dataRoot), { force: true });
diff --git a/packages/cli/src/notifier.mjs b/packages/cli/src/notifier.mjs
index a4bb78d2..0610e1f5 100644
index a4bb78d2..2a392b83 100644
--- a/packages/cli/src/notifier.mjs
+++ b/packages/cli/src/notifier.mjs
@@ -4,17 +4,22 @@
@@ -4,17 +4,25 @@
// bus; its memory is the journal `<dataRoot>/notify/<business>/sent.jsonl`
// (0600, in a 0700 directory), one line per send attempt:
//
@@ -83,10 +114,13 @@ index a4bb78d2..0610e1f5 100644
// doubling to 30 min): a duplicate costs less than a miss, and Discord's
// nonce folds a retry inside its dedupe window into the first message.
+// The exception is a definite refusal, an HTTP 4xx other than 429 (lead
+// decision 72): after five for one decision, counted from the journal so a
+// restart keeps the count, the notifier appends one `gave-up` line, logs
+// once and stops sending that DM. The digest marks it "DM refused, not
+// retried". Unknown outcomes (network, 5xx, 429) retry without a limit.
+// decision 72): the next try waits the full 30 min, counted from the
+// refusal's line so a restart cannot shorten it (lead decision 73), and
+// after five for one decision, counted from the journal, the notifier
+// appends one `gave-up` line, logs once and stops sending that DM. Five
+// tries span two hours, long enough to fix a binding or token typo. The
+// digest marks it "DM refused, not retried"; recovery is the operator
+// deciding it. Unknown outcomes (network, 5xx, 429) retry without a limit.
// No Discord channel or user id goes in the journal, a log line or an
// error; the Discord side (packages/discord/src/notify.mjs) keeps them.
@@ -96,7 +130,7 @@ index a4bb78d2..0610e1f5 100644
import { dirname, join } from "node:path";
import { CliError } from "./errors.mjs";
import { authorizationLines, optionsLine, shortId } from "./format.mjs";
@@ -25,6 +30,7 @@ export const POLL_MS = 30000;
@@ -25,6 +33,7 @@ export const POLL_MS = 30000;
const BACKOFF_MS = 30000;
const BACKOFF_MAX_MS = 30 * 60 * 1000;
const LIMIT = 2000;
@@ -104,7 +138,7 @@ index a4bb78d2..0610e1f5 100644
export const journalPath = (dataRoot, business) => join(dataRoot, "notify", business, "sent.jsonl");
@@ -62,14 +68,14 @@ export function dmContent(business, d) {
@@ -62,14 +71,14 @@ export function dmContent(business, d) {
return clip(lines.join("\n"), LIMIT);
}
@@ -121,12 +155,13 @@ index a4bb78d2..0610e1f5 100644
const line = `- ${mark}${shortId(d.id)} ${d.action}: ${clip(d.question.replace(/\s+/g, " "), 160)}`;
const more = inbox.length - shown - 1;
const reserve = more > 0 ? `\n… and ${more} more.`.length : 0;
@@ -116,9 +122,31 @@ function copyTorn(dir, bytes, date) {
@@ -116,9 +125,32 @@ function copyTorn(dir, bytes, date) {
}
}
+const OUTCOMES = ["confirmed", "refused", "unknown", "gave-up"];
+const DAY = /^\d{4}-\d{2}-\d{2}$/;
+const isDay = (d) => typeof d === "string" && DAY.test(d) && !Number.isNaN(Date.parse(d)) && new Date(d).toISOString().startsWith(d);
+const UNWRITABLE = ["EACCES", "EPERM", "EROFS"];
+
+// The first field of a journal record that has the wrong type, or null
@@ -136,7 +171,7 @@ index a4bb78d2..0610e1f5 100644
+ if (typeof r.at !== "string" || Number.isNaN(Date.parse(r.at)) || new Date(r.at).toISOString() !== r.at) return "at";
+ if (!["dm", "digest"].includes(r.kind)) return "kind";
+ if (r.kind === "dm" ? typeof r.decision !== "string" || r.decision === "" : r.decision !== null && r.decision !== undefined) return "decision";
+ if (r.kind === "digest" ? typeof r.day !== "string" || !DAY.test(r.day) : r.day !== undefined && (typeof r.day !== "string" || !DAY.test(r.day))) return "day";
+ if (r.kind === "digest" ? !isDay(r.day) : r.day !== undefined && !isDay(r.day)) return "day";
+ if (!OUTCOMES.includes(r.outcome) || (r.outcome === "gave-up" && r.kind !== "dm")) return "outcome";
+ if (r.outcome === "confirmed" ? typeof r.messageId !== "string" : r.messageId !== null && typeof r.messageId !== "string") return "messageId";
+ if (r.status !== undefined && !Number.isInteger(r.status)) return "status";
@@ -155,7 +190,7 @@ index a4bb78d2..0610e1f5 100644
// A final line without its newline is a write that never finished (lead
// decision 71): its bytes are copied to torn-<stamp>.bin, then the journal
// is truncated to its last newline and fsynced, and both steps are logged.
@@ -126,20 +154,42 @@ function copyTorn(dir, bytes, date) {
@@ -126,20 +158,49 @@ function copyTorn(dir, bytes, date) {
// both; the second copy is harmless.
export function openJournal(file, { log = () => {}, now = () => new Date() } = {}) {
const dir = dirname(file);
@@ -163,7 +198,9 @@ index a4bb78d2..0610e1f5 100644
+ try {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ } catch (e) {
+ if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
+ // A dangling link in place of the directory fails mkdir with ENOENT.
+ if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
+ if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
+ if (e.code !== "EEXIST") throw e;
+ }
const ds = lstatSync(dir);
@@ -183,15 +220,20 @@ index a4bb78d2..0610e1f5 100644
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
+ if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
+ if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw e;
}
const sent = new Set();
const days = new Set();
+ const refusals = new Map();
+ const gaveUp = new Set();
+ const refusedAt = new Map();
+ const count = (r) => {
+ if (r.kind === "dm" && r.outcome === "gave-up") gaveUp.add(r.decision);
+ if (definite(r)) refusals.set(r.decision, (refusals.get(r.decision) ?? 0) + 1);
+ if (definite(r)) {
+ refusals.set(r.decision, (refusals.get(r.decision) ?? 0) + 1);
+ refusedAt.set(r.decision, Date.parse(r.at));
+ }
+ if (r.outcome !== "confirmed") return;
+ if (r.kind === "dm") sent.add(r.decision);
+ else days.add(r.day);
@@ -199,7 +241,7 @@ index a4bb78d2..0610e1f5 100644
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
@@ -156,12 +206,9 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
@@ -156,12 +217,9 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
} catch {
r = null;
}
@@ -215,16 +257,17 @@ index a4bb78d2..0610e1f5 100644
});
if (end < bytes.length) {
const name = copyTorn(dir, bytes.subarray(end), now());
@@ -176,6 +223,8 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
@@ -176,6 +234,9 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
return {
sent,
days,
+ refusals,
+ gaveUp,
+ refusedAt,
append(record) {
const afd = openSync(file, O_WRONLY | O_APPEND | O_NOFOLLOW);
try {
@@ -183,9 +232,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
@@ -183,9 +244,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
} finally {
closeSync(afd);
}
@@ -235,7 +278,7 @@ index a4bb78d2..0610e1f5 100644
},
};
}
@@ -202,6 +249,13 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
@@ -202,6 +261,13 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
return b !== undefined && t < b.next;
}
@@ -249,18 +292,27 @@ index a4bb78d2..0610e1f5 100644
async function attempt(key, record, message) {
const t = now().getTime();
try {
@@ -215,6 +269,10 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
@@ -212,10 +278,16 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
} catch (err) {
const kind = err?.kind === "refused" ? "refused" : "unknown";
const status = Number.isInteger(err?.details?.status) ? err.details.status : null;
+ const line = { at: new Date(t).toISOString(), ...record, outcome: kind, messageId: null, ...(status !== null ? { status } : {}) };
const n = (backoff.get(key)?.n ?? -1) + 1;
backoff.set(key, { n, next: t + Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) });
journal.append({ at: new Date(t).toISOString(), ...record, outcome: kind, messageId: null, ...(status !== null ? { status } : {}) });
- backoff.set(key, { n, next: t + Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) });
- journal.append({ at: new Date(t).toISOString(), ...record, outcome: kind, messageId: null, ...(status !== null ? { status } : {}) });
- log(`notify: ${record.kind} ${kind}${status !== null ? ` (HTTP ${status})` : ""}; retry in ${Math.round(Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) / 1000)} s`);
+ const wait = definite(line) ? BACKOFF_MAX_MS : Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS);
+ backoff.set(key, { n, next: t + wait });
+ journal.append(line);
+ if (record.kind === "dm" && (journal.refusals.get(record.decision) ?? 0) >= REFUSAL_LIMIT) {
+ giveUp(record.decision, t);
+ return false;
+ }
log(`notify: ${record.kind} ${kind}${status !== null ? ` (HTTP ${status})` : ""}; retry in ${Math.round(Math.min(BACKOFF_MS * 2 ** n, BACKOFF_MAX_MS) / 1000)} s`);
+ log(`notify: ${record.kind} ${kind}${status !== null ? ` (HTTP ${status})` : ""}; retry in ${Math.round(wait / 1000)} s`);
return false;
}
@@ -232,7 +290,12 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
}
@@ -232,7 +304,15 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
}
const t = now();
for (const d of list) {
@@ -271,10 +323,13 @@ index a4bb78d2..0610e1f5 100644
+ giveUp(d.id, t.getTime());
+ continue;
+ }
+ // A definite refusal waits the full cap from its journal line, which
+ // the in-memory backoff loses on a restart.
+ if (t.getTime() < (journal.refusedAt.get(d.id) ?? -Infinity) + BACKOFF_MAX_MS) continue;
const key = `dm:${d.id}`;
if (waiting(key, t.getTime())) continue;
if (await attempt(key, { kind: "dm", decision: d.id }, { content: dmContent(business, d), nonce: dmNonce(d.id) })) done.dms++;
@@ -241,7 +304,7 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
@@ -241,7 +321,7 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
const { day, hour } = zoned(t, zone);
const key = `digest:${day}`;
if (hour >= DIGEST_HOUR && !journal.days.has(day) && !waiting(key, t.getTime())) {
@@ -284,7 +339,7 @@ index a4bb78d2..0610e1f5 100644
else done.failed++;
}
diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs
index 8d20b47f..649031ae 100644
index 8d20b47f..f8f11ec9 100644
--- a/packages/cli/tests/host.test.mjs
+++ b/packages/cli/tests/host.test.mjs
@@ -1,6 +1,7 @@
@@ -295,7 +350,7 @@ index 8d20b47f..649031ae 100644
import { createServer } from "node:http";
import { once } from "node:events";
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
@@ -48,6 +49,31 @@ async function until(fn, ms = 8000) {
@@ -48,6 +49,70 @@ async function until(fn, ms = 8000) {
throw new Error("timed out waiting");
}
@@ -323,11 +378,50 @@ index 8d20b47f..649031ae 100644
+ t.after(() => unsubscribe("child_process", onChild));
+ return sent;
+}
+
+// Fails this process's sends to new children the way node fails a write to a
+// child that has just died: `fake(child, m)` returns an error to fail that
+// send (to its callback if it has one, otherwise as an 'error' event on the
+// next tick), or nothing to send it for real. Deterministic where the real
+// window after a SIGKILL is a millisecond wide (Darkwing R1, lead decision 73).
+// Every child it saw is killed after the test, so a host left half closed
+// fails the test rather than hang it.
+function failSends(t, fake) {
+ const children = [];
+ const onChild = ({ process: child }) => {
+ children.push(child);
+ let send;
+ Object.defineProperty(child, "send", {
+ configurable: true,
+ get: () => send,
+ set(fn) {
+ send = function (m, ...rest) {
+ const err = fake(this, m);
+ if (!err) return fn.call(this, m, ...rest);
+ const callback = rest.find((a) => typeof a === "function");
+ if (callback) process.nextTick(callback, err);
+ else process.nextTick(() => this.emit("error", err));
+ return false;
+ };
+ },
+ });
+ };
+ subscribe("child_process", onChild);
+ t.after(() => {
+ unsubscribe("child_process", onChild);
+ for (const child of children) child.kill("SIGKILL");
+ });
+}
+
+const epipe = (child) => {
+ child.kill("SIGKILL");
+ return Object.assign(new Error("write EPIPE"), { code: "EPIPE", errno: -32, syscall: "write" });
+};
+
const procText = (pid, what) => {
try {
return readFileSync(`/proc/${pid}/${what}`, "utf8");
@@ -64,8 +90,12 @@ test("the host boots the broker, binds a launch in process, and the notifier DMs
@@ -64,8 +129,12 @@ test("the host boots the broker, binds a launch in process, and the notifier DMs
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
assert.equal("trackers" in boot, false);
const logs = [];
@@ -340,7 +434,7 @@ index 8d20b47f..649031ae 100644
const state = JSON.parse(readFileSync(hostFile(f.dataRoot), "utf8"));
assert.equal(statSync(hostFile(f.dataRoot)).mode & 0o777, 0o600);
@@ -84,12 +114,16 @@ test("the host boots the broker, binds a launch in process, and the notifier DMs
@@ -84,12 +153,16 @@ test("the host boots the broker, binds a launch in process, and the notifier DMs
assert.ok(discord.requests.every((r) => r.authorized));
assert.match(discord.dms()[0].body.content, new RegExp(`mosaic decide ${d.id.slice(0, 8)}`));
@@ -362,7 +456,7 @@ index 8d20b47f..649031ae 100644
assert.equal(await host.close(0), 0);
const journal = readFileSync(journalPath(f.dataRoot, "acme"), "utf8");
@@ -107,6 +141,7 @@ test("a notifier that dies takes the host down with exit 1, so the unit restarts
@@ -107,6 +180,7 @@ test("a notifier that dies takes the host down with exit 1, so the unit restarts
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const logs = [];
const host = await startHost({ boot, business: "acme", notifier: { binding: "test-seat", base: discord.base, pollMs: 100 }, log: (l) => logs.push(l) });
@@ -370,7 +464,7 @@ index 8d20b47f..649031ae 100644
process.kill(host.pids.notifier, "SIGKILL");
assert.equal(await host.done, 1);
assert.match(logs.join("\n"), /notifier exited \(SIGKILL\); stopping the host/);
@@ -114,6 +149,21 @@ test("a notifier that dies takes the host down with exit 1, so the unit restarts
@@ -114,6 +188,21 @@ test("a notifier that dies takes the host down with exit 1, so the unit restarts
assert.equal(existsSync(hostFile(f.dataRoot)), false);
});
@@ -392,7 +486,7 @@ index 8d20b47f..649031ae 100644
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
@@ -127,9 +177,68 @@ test("a notifier that refuses stops the broker and the host refuses with exit 3"
@@ -127,9 +216,111 @@ test("a notifier that refuses stops the broker and the host refuses with exit 3"
assert.equal(existsSync(hostFile(f.dataRoot)), false);
});
@@ -447,6 +541,49 @@ index 8d20b47f..649031ae 100644
+ assert.deepEqual(errors, [], "no close was sent over the closed channel");
+ assert.equal(sends.filter((m) => m?.op === "close").length, 0);
+});
+
+test("a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3", async (t) => {
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ failSends(t, (child, m) => (m?.op === "close" ? epipe(child) : null));
+ const started = startHost({ boot, business: "acme", notifier: { binding: "test-seat" }, log: () => {} });
+ t.after(async () => (await started.catch(() => null))?.close(0));
+ await assert.rejects(started, (e) => e.exitCode === 3 && /^notifier refused to start: .*no dmRecipient/.test(e.message));
+});
+
+test("a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error", async (t) => {
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ failSends(t, (child, m) => {
+ if (m?.op === "close") return epipe(child);
+ if (m?.op === "start") {
+ // Stopped, it cannot reply; then it dies.
+ child.kill("SIGSTOP");
+ setImmediate(() => child.kill("SIGKILL"));
+ }
+ return null;
+ });
+ const started = startHost({ boot, business: "acme", notifier: { binding: "test-seat" }, log: () => {} });
+ t.after(async () => (await started.catch(() => null))?.close(0));
+ await assert.rejects(started, (e) => e.exitCode === 1 && /^notifier exited \(null\) before it replied/.test(e.message));
+});
+
+test("close() whose stop and close sends fail with EPIPE still finishes, with exit 1", async (t) => {
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root, { dmRecipient: IDS.owner });
+ const discord = await fakeDiscord(t);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ failSends(t, (child, m) => (m?.op === "stop" || m?.op === "close" ? epipe(child) : null));
+ const host = await startHost({ boot, business: "acme", notifier: { binding: "test-seat", base: discord.base, pollMs: 100 }, log: () => {} });
+ // No t.after close: a close() that rejected leaves `done` pending forever.
+ assert.equal(await host.close(0), 1, "both children died by signal");
+ assert.equal(existsSync(hostFile(f.dataRoot)), false);
+});
+
test("watchChildren reports a child that died before it was called, and one that dies later", async (t) => {
const early = spawn(process.execPath, ["-e", "process.exit(7)"], { stdio: "ignore" });
@@ -461,8 +598,16 @@ index 8d20b47f..649031ae 100644
const late = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
t.after(() => late.kill("SIGKILL"));
await once(late, "spawn");
@@ -215,6 +406,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
+ assert.match(first.stdout, /^ {2}mkdir -m 0700 -p <dataRoot>\/notify\/<business> +the notifier refuses a looser directory$/m);
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /);
diff --git a/packages/cli/tests/notifier.test.mjs b/packages/cli/tests/notifier.test.mjs
index 99127662..aa81ac69 100644
index 99127662..3b65af47 100644
--- a/packages/cli/tests/notifier.test.mjs
+++ b/packages/cli/tests/notifier.test.mjs
@@ -1,12 +1,15 @@
@@ -472,7 +617,7 @@ index 99127662..aa81ac69 100644
+import { appendFileSync, chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
-import { createNotifier, digestContent, digestNonce, dmNonce, journalPath, openJournal, runLoop, zoned } from "../src/notifier.mjs";
+import { createNotifier, digestContent, digestNonce, dmNonce, journalPath, openJournal, REFUSAL_LIMIT, runLoop, zoned } from "../src/notifier.mjs";
+import { createNotifier, digestContent, digestNonce, dmNonce, journalPath, openJournal, POLL_MS, REFUSAL_LIMIT, runLoop, zoned } from "../src/notifier.mjs";
import { RestOutcome } from "../../discord/src/rest.mjs";
import { broker, tmp } from "./helpers.mjs";
@@ -493,10 +638,25 @@ index 99127662..aa81ac69 100644
},
};
}
@@ -101,6 +105,72 @@ test("a failed DM is journaled, backs off, and is retried until it lands", async
assert.ok(s.logs.some((l) => /retry in 30 s/.test(l)));
});
@@ -91,14 +95,117 @@ test("a failed DM is journaled, backs off, and is retried until it lands", async
assert.equal((await s.notifier.tick()).failed, 0, "inside the first 30 s backoff");
s.time.advance(25_000);
assert.equal((await s.notifier.tick()).failed, 1, "second attempt refused");
- s.time.advance(45_000);
- assert.equal((await s.notifier.tick()).dms, 0, "inside the 60 s backoff");
- s.time.advance(20_000);
+ s.time.advance(29 * 60_000);
+ assert.equal((await s.notifier.tick()).dms, 0, "a definite refusal waits the full 30 min, not the 60 s step");
+ s.time.advance(60_000);
assert.equal((await s.notifier.tick()).dms, 1);
assert.deepEqual(s.journal().map((r) => r.outcome), ["unknown", "refused", "confirmed"]);
assert.equal(s.journal()[1].status, 403);
assert.equal(new Set(s.direct.sends.map((m) => m.nonce)).size, 1, "every retry reuses the nonce");
- assert.ok(s.logs.some((l) => /retry in 30 s/.test(l)));
+ assert.ok(s.logs.some((l) => /unknown; retry in 30 s/.test(l)));
+ assert.ok(s.logs.some((l) => /refused \(HTTP 403\); retry in 1800 s/.test(l)));
+});
+
+// 05:00Z is 00:00 Chicago: eight hours of polls before the digest is due.
+const MIDNIGHT = "2026-10-08T05:00:00Z";
+const PAST_BACKOFF = 31 * 60_000;
@@ -563,10 +723,44 @@ index 99127662..aa81ac69 100644
+ assert.equal(s.journal().length, REFUSAL_LIMIT + 1, "one gave-up line only");
+});
+
+// Polls every POLL_MS from the current clock up to `until` ms later; returns
+// the minute (from `from`) of each new send.
+async function pollUntil(s, notifier, from, until, onPoll = () => {}) {
+ const minutes = [];
+ for (let elapsed = s.time.clock.t.getTime() - from; elapsed <= until; elapsed += POLL_MS) {
+ const before = s.direct.sends.length;
+ await notifier.tick();
+ if (s.direct.sends.length > before) minutes.push(elapsed / 60_000);
+ onPoll(elapsed);
+ s.time.advance(POLL_MS);
+ }
+ return minutes;
+}
+
+test("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", async (t) => {
+ const s = setup(t, MIDNIGHT, Array(10).fill("refused"));
+ s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
+ const from = s.time.clock.t.getTime();
+ const gaveUp = () => s.journal().some((r) => r.outcome === "gave-up");
+ const minutes = await pollUntil(s, s.notifier, from, 120 * 60_000 + POLL_MS, (elapsed) => {
+ assert.equal(gaveUp(), elapsed >= 120 * 60_000, `gave-up line at +${elapsed / 1000} s`);
+ });
+ assert.deepEqual(minutes, [0, 30, 60, 90, 120]);
+ assert.equal(s.logs.filter((l) => /refused \(HTTP 403\); retry in 1800 s/.test(l)).length, 4);
+});
+
+test("a restart after the second refusal does not send before that refusal's 30 min are up", async (t) => {
+ const s = setup(t, MIDNIGHT, Array(10).fill("refused"));
+ s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
+ const from = s.time.clock.t.getTime();
+ assert.deepEqual(await pollUntil(s, s.notifier, from, 30 * 60_000), [0, 30]);
+ s.time.advance(60_000);
+ const after = s.make();
+ assert.deepEqual(await pollUntil(s, after, from, 60 * 60_000), [60], "nothing between the restart and +60 min");
});
test("the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd", async (t) => {
const s = setup(t, "2026-10-08T12:59:00Z");
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
@@ -150,7 +220,7 @@ const FRAGMENT = '{"at":"x","kind":"dm","dec';
@@ -150,7 +257,7 @@ const FRAGMENT = '{"at":"x","kind":"dm","dec';
test("the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly", (t) => {
const file = journalPath(tmp(t), "demo");
@@ -575,7 +769,7 @@ index 99127662..aa81ac69 100644
const good = readFileSync(file);
appendFileSync(file, FRAGMENT);
const logs = [];
@@ -164,7 +234,7 @@ test("the journal: a torn tail is copied out and truncated, so an append after i
@@ -164,7 +271,7 @@ test("the journal: a torn tail is copied out and truncated, so an append after i
assert.equal(logs.length, 2);
assert.match(logs[0], /copied a torn final line \(26 bytes\) to torn-20261008T235212345Z\.bin/);
assert.match(logs[1], /truncated .* to its last newline/);
@@ -584,7 +778,7 @@ index 99127662..aa81ac69 100644
const again = [];
assert.deepEqual([...openJournal(file, { log: (l) => again.push(l) }).sent], ["a", "b"]);
assert.deepEqual(again, [], "nothing torn the second time");
@@ -172,7 +242,7 @@ test("the journal: a torn tail is copied out and truncated, so an append after i
@@ -172,7 +279,7 @@ test("the journal: a torn tail is copied out and truncated, so an append after i
test("the journal: a crash between the copy and the truncate leaves a tail the next open repairs", (t) => {
const file = journalPath(tmp(t), "demo");
@@ -593,7 +787,7 @@ index 99127662..aa81ac69 100644
appendFileSync(file, FRAGMENT);
const now = () => new Date("2026-10-08T23:52:12.345Z");
// The log after step 1 throws: the process dies before step 2.
@@ -182,7 +252,7 @@ test("the journal: a crash between the copy and the truncate leaves a tail the n
@@ -182,7 +289,7 @@ test("the journal: a crash between the copy and the truncate leaves a tail the n
const j = openJournal(file, { now });
assert.deepEqual(tornFiles(file), ["torn-20261008T235212345Z-1.bin", "torn-20261008T235212345Z.bin"], "a second copy, the first kept");
for (const n of tornFiles(file)) assert.equal(readFileSync(join(dirname(file), n), "utf8"), FRAGMENT);
@@ -602,7 +796,7 @@ index 99127662..aa81ac69 100644
assert.deepEqual([...openJournal(file).sent], ["a", "b"]);
});
@@ -222,6 +292,89 @@ test("the journal: a loose file mode, a loose directory or a symlinked journal r
@@ -222,6 +329,108 @@ test("the journal: a loose file mode, a loose directory or a symlinked journal r
assert.throws(() => openJournal(linked), (e) => e.exitCode === 3 && /must not be a symlink/.test(e.message));
});
@@ -622,6 +816,8 @@ index 99127662..aa81ac69 100644
+ ["decision", { ...digest, decision: "a" }],
+ ["day", { ...digest, day: undefined }],
+ ["day", { ...digest, day: "2026-10-8" }],
+ ["day", { ...digest, day: "2026-13-45" }],
+ ["day", { ...digest, day: "2026-02-30" }],
+ ["day", { ...dm, day: 20261008 }],
+ ["outcome", { ...dm, outcome: "sent" }],
+ ["outcome", { ...digest, outcome: "gave-up", messageId: null }],
@@ -656,6 +852,20 @@ index 99127662..aa81ac69 100644
+ assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
+});
+
+test("the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3", (t) => {
+ const root = tmp(t);
+ const dangling = journalPath(root, "demo");
+ mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
+ symlinkSync(join(root, "missing"), dirname(dangling));
+ assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
+ const under = join(root, "plain", "notify", "demo", "sent.jsonl");
+ writeFileSync(join(root, "plain"), "");
+ assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
+ const asDir = journalPath(root, "acme");
+ mkdirSync(asDir, { recursive: true, mode: 0o700 });
+ assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
+});
+
+test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
+ const root = tmp(t);
+ const file = journalPath(root, "demo");
@@ -679,6 +889,9 @@ index 99127662..aa81ac69 100644
+ } finally {
+ chmodSync(dirname(file), 0o700);
+ }
+ openJournal(file);
+ chmodSync(file, 0o400);
+ assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
+ const parent = join(root, "notify");
+ const other = journalPath(root, "acme");
+ chmodSync(parent, 0o500);
@@ -1,8 +1,8 @@
6c777639fae9a9332726260c18ba62c5b942d56601a7d0e7ffe969b214a6f960 packages/cli/README.md
e63b592d249ab1bebb1b3fbe7e61372270b7858c9b1230e3f2ecf48476ec053e packages/cli/src/host.mjs
3f5bbbfcd3a64db07ceff7aef183f7843d6d541246cc51bf17ae3879eb59ebbd packages/cli/src/notifier.mjs
628deae836f9d79e646bc8bba99ad05ab587339cc3f057ad42caa600b0f00c7b packages/cli/tests/host.test.mjs
ec6dce1b2bf0af9635c0128b11542db8bcdb5f2dae846ce5a7005fa71c9a9b59 packages/cli/tests/notifier.test.mjs
33835702af268d8337c68a9d62ab7340174ec6ec990a86422c099a922feba61d packages/cli/README.md
24461cccd45d08cf4b5b77d2b44bd1792002bcaca028844b1f0f013052d097d6 packages/cli/src/host.mjs
cb27929bd2d17704ff7db42a0b001d97cb626ec79fa833add4bdaff2e304a4c3 packages/cli/src/notifier.mjs
b998202b4872c4929b1aeb601ed26da4bdaef8549ae0676ce650a23fa4f0f38c packages/cli/tests/host.test.mjs
38d499cf3f2a0b78fb08d473946ea6517cc8a6b35681bd077695ddb507fb3cc6 packages/cli/tests/notifier.test.mjs
f015ef5ed6ae2fa6ec40b2b0d5148baf23436917f71a7fa5521249864fdde0c4 packages/cli/tests/trackers-boot.test.mjs
219924be715db3cbfc6030c4eafd57b27189d6d971b6da26f719b2f86599d37a packages/discord/tests/journal.test.mjs
bc7abfb98e0ffa8c0d1111069c05ac19d9cc9be3b0ed4d457c4f135f97d08bb1 scripts/bus-service.sh
+18 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Row 45 mutants: N2, N4, N5 and M28 from the row 39 reviews, the F2 limit,
# J4 and the new error paths. Same harness as
# J4 and the new error paths. Round 2 adds the send callbacks (R1), the
# refusal spacing and restart (R2, lead decision 73), X9, X14, the day check
# and the error paths from Darkwing's note 1. Same harness as
# agents/filbert/work/slice1-s4-review/mutants.sh: one perl substitution
# each, restored after its run. A kill counts only `fail`; the cancelled
# count is printed too, and the output names every failing test.
@@ -38,3 +40,18 @@ run J4c $NT 's/if \(r\.status !== undefined && !Number\.isInteger\(r\.status\)\
run J4d $NT 's/r\.outcome === "confirmed" \? typeof r\.messageId !== "string" : //'
run E1 $NT 's/accessSync\(dir, constants\.W_OK \| constants\.X_OK\);//'
run E2 $NT 's/ if \(ds\.isSymbolicLink\(\)\) throw[^\n]*\n//'
BS=scripts/bus-service.sh
# Round 2.
run G144cb $HS 's/(notify\.kill\("SIGTERM"\);\n\s*await ended\(notify, 5000\);\n\s*if \(broker\.connected\) broker\.send\(\{ op: "close" \}), \(\) => \{\}\)/$1)/'
run G150cb $HS 's/(if \(ok\?\.ok !== true\) \{\n\s*await ended\(notify, 5000\);\n\s*if \(broker\.connected\) broker\.send\(\{ op: "close" \}), \(\) => \{\}\)/$1)/'
run G184 $HS 's/notify\.send\(\{ op: "stop" \}, \(\) => \{\}\)/notify.send({ op: "stop" })/'
run G188 $HS 's/(await queue;\n\s*if \(broker\.connected\) broker\.send\(\{ op: "close" \}), \(\) => \{\}\)/$1)/'
run R2a $NT 's/const wait = definite\(line\) \? BACKOFF_MAX_MS : /const wait = /'
run R2b $NT 's/ if \(t\.getTime\(\) < \(journal\.refusedAt\.get\(d\.id\)[^\n]*\n//'
run R2c $NT 's/ refusedAt\.set\(r\.decision, Date\.parse\(r\.at\)\);\n//'
run X9 $NT 's/ if \(UNWRITABLE\.includes\(e\.code\)\) throw new CliError\(`notify journal is not writable[^\n]*\n//'
run X14 $BS 's/ mkdir -m 0700 -p <dataRoot>\/notify\/<business>[^\n]*\n//'
run D3 $NT 's/ && !Number\.isNaN\(Date\.parse\(d\)\) && new Date\(d\)\.toISOString\(\)\.startsWith\(d\)//'
run N1a $NT 's/ if \(e\.code === "ENOENT" && lstatSync\(dir[^\n]*\n//'
run N1b $NT 's/\[\.\.\.UNWRITABLE, "ENOTDIR"\]/UNWRITABLE/'
run N1c $NT 's/ if \(e\.code === "EISDIR"\)[^\n]*\n//'
@@ -0,0 +1,2 @@
2.44 1.79 2.16 1/12979 779335
2026-10-09T14:05:56Z
@@ -0,0 +1,21 @@
node-business exit 0
node-bus exit 0
node-cli exit 0
node-control-board exit 0
node-conversation exit 0
node-discord exit 0
node-ledger exit 0
node-mosaic exit 0
node-queue exit 0
node-seat exit 0
node-tasks exit 0
node-webui exit 0
suite-auth exit 0
suite-conductor exit 0
suite-config exit 0
suite-discord exit 0
suite-extension-package exit 0
suite-foundation exit 0
suite-queue exit 0
suite-release exit 0
suite-task exit 1
@@ -0,0 +1,2 @@
1.01 0.95 2.02 7/12984 683241
2026-10-09T14:02:34Z
@@ -0,0 +1,8 @@
packages/cli/README.md: OK
packages/cli/src/host.mjs: OK
packages/cli/src/notifier.mjs: OK
packages/cli/tests/host.test.mjs: OK
packages/cli/tests/notifier.test.mjs: OK
packages/cli/tests/trackers-boot.test.mjs: OK
packages/discord/tests/journal.test.mjs: OK
scripts/bus-service.sh: OK
@@ -0,0 +1,75 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (20.217177ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (23.913734ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (27.418015ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (20.095476ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (15.028242ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (13.46164ms)
✔ task action subjects and linked decision trail are complete and ordered (20.841526ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (8.013879ms)
✔ business isolation includes inherited object names and cross-business message references (14.650376ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (18.622725ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (9.427439ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (15.623429ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (10.401472ms)
✔ both arbiters require human resolution when their cross-role route is themselves (15.860762ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (1.726404ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (3.477977ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.348156ms)
✔ expiry refuses use and env references never become client data (0.808375ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.64772ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.37342ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.132056ms)
✔ process reader gets own kernel identity without exposing environment values (1.287166ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.788136ms)
✔ real pid 1 remains inspectable when its environment is protected (0.359006ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (24.288305ms)
✔ missing and foreign-business citations refuse and roll back message and grant (17.011547ms)
✔ cross-role sends still need a matching resolved decision and consume it once (28.381359ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (66.723278ms)
✔ startup token refusal returns safe code without value or partial listening broker (38.97226ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (49.827986ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (82.849468ms)
✔ trusted host registers later launches; socket clients never have a registration verb (54.101581ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (37.386095ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (48.361311ms)
✔ v3b prototype refusals, views and append-only mutations (82.523597ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (27.788071ms)
✔ another run cannot consume an approval; a failed check leaves it usable (20.309447ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (20.492887ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (29.50709ms)
✔ class drift gated to cross-role refuses before consumption (19.187248ms)
✔ class drift cross-role to gated refuses before consumption (14.978956ms)
✔ class drift gated to within-role refuses before consumption (16.443241ms)
✔ class drift cross-role to within-role refuses before consumption (16.072617ms)
✔ class drift within-role to gated refuses before consumption (15.064451ms)
✔ class drift within-role to cross-role refuses before consumption (15.61633ms)
✔ message.send consumes approval and prevents a later send or authorize (15.913262ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (16.694773ms)
✔ creates private WAL store and excludes a second writer until explicit close (15.558522ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (15.837274ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (15.430354ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (51.488693ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (9.684961ms)
✔ async transactions refuse before invoking their function (7.964162ms)
✔ recordTask keeps sync reads and a role write apart (20.233791ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (9.909358ms)
✔ a bad entry refuses the whole record (9.524417ms)
✔ taskView reads the projection for one business (16.739565ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (21.811716ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (269.121186ms)
✔ without an adapter the server refuses every task verb (15.529542ms)
✔ the runtime refuses an invalid adapter and closes a valid one (14.405829ms)
✔ the process loads the S3 adapter from plain-data trackers (87.38528ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (24.374712ms)
✔ two wire claims serialize; a lost reply never automatically retries (18.879726ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (16.264802ms)
✔ client preserves UTF-8 when a response divides a multibyte character (11.772894ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (13.790622ms)
ℹ tests 67
ℹ suites 0
ℹ pass 67
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 577.642302
@@ -0,0 +1,68 @@
✔ config directory and file path follow MOSAIC_CONFIG (1.452137ms)
✔ the fixture business validates and comes back frozen (5.578344ms)
✔ two instances may share a definition (1.441253ms)
✔ top-level refusals (5.152839ms)
✔ arbiters and projects (7.563057ms)
✔ role instances (3.855653ms)
✔ Vikunja bots (9.49824ms)
✔ a role without Vikunja takes no tracker block (3.98548ms)
✔ credential references match the definition's services (5.343946ms)
✔ launch (10.588077ms)
✔ loadBusiness: file checks (3.090109ms)
✔ loadBusiness: not a regular file (42.83915ms)
✔ loading writes nothing (1.21552ms)
✔ names that are Object.prototype properties don't count as declared (2.449986ms)
✔ the shipped example refuses as written and validates once filled in (1.466161ms)
✔ usage errors exit 4 (285.939347ms)
✔ validate: a good business exits 0 and prints instance digests (65.835211ms)
✔ validate: project files (316.787862ms)
✔ validate: missing files and a broken system config (242.410619ms)
✔ validate: credential reference problems exit 2 and name each one (66.429598ms)
✔ validate: a token file inside the repository is refused (61.975689ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (189.828413ms)
✔ resolve: prints one instance's record (192.088605ms)
✔ resolve: refusals (370.817334ms)
✔ parse: exactly one of file or env, plus the service's date (2.912184ms)
✔ check: a good file has no problems (0.697025ms)
✔ check never opens the file: a write-only token passes (0.352511ms)
✔ check: file problems (0.754102ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (0.720844ms)
✔ check: dates and environment references (0.368981ms)
✔ path and load (2.114907ms)
✔ refusals (1.225659ms)
✔ systemVars flattens the validated config (2.575859ms)
✔ precedence: system, business, project, project role, agent (8.530151ms)
✔ limits narrow the definition and never widen it (3.350867ms)
✔ role.launch stays within-role only for the instance the launch block names (7.962531ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (2.886369ms)
✔ limits.authority narrows cross-role actions too (2.092685ms)
✔ classify (2.292167ms)
✔ the record carries what the broker and launcher need (1.011055ms)
✔ digest: key order doesn't matter, any value change does (7.193711ms)
✔ refusals (2.432054ms)
✔ the four shipped version 2 roles load (3.955928ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (1.776154ms)
✔ shipped authority follows the note's table (1.111235ms)
✔ version 1 files keep loading with no authority (1.386411ms)
✔ the conductor policy isn't a role (0.428507ms)
✔ a missing role file is exit 4, a symbolic link too (0.540084ms)
✔ version 2 refusals (5.32113ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (3.077258ms)
✔ credentials: Gitea scopes (1.661659ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (2.161649ms)
✔ credentials: services (0.922355ms)
✔ contract: a non-empty regular Markdown file beside the role file (0.714248ms)
✔ every key names known layers and a merge rule (1.083157ms)
✔ unknown keys and wrong layers refuse (0.688039ms)
✔ types (2.09786ms)
✔ merge: defaults, then the most specific layer wins (0.434102ms)
✔ merge: limits only narrow, and provenance lists each source (0.444915ms)
✔ merge doesn't change its inputs (0.176633ms)
ℹ tests 60
ℹ suites 0
ℹ pass 60
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1866.960358
@@ -0,0 +1,76 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (15.406365ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (19.889859ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (11.082826ms)
✔ decide prints a declining choice as declining (10.900547ms)
✔ an unknown outcome is reported once and never resent (9.993144ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (9.741018ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (11.82987ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (10.354325ms)
✔ every human command refuses inside an agent run before it touches the bus (9.997008ms)
✔ usage errors exit 4; no business and no host is a usage error (10.647727ms)
✔ agents and tasks print through the broker (10.313741ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.332501ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (39.17658ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (33.4277ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (28.461035ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (33.735066ms)
✔ a business without tracker.baseUrl gets no trackers entry (40.658634ms)
✔ an unknown business and a broken system config refuse with exit 3 (62.480257ms)
✔ empty views say so (0.745845ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (0.7702ms)
✔ tasks print the tracker fields the snapshot carries (0.123489ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (861.401799ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (134.37757ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (72.16873ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (124.331709ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (118.368837ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (78.525932ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (123.149211ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (76.627673ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (130.111723ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.373132ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.144574ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (204.506438ms)
✔ bus start refuses with exit 3 without a notifier config (80.300161ms)
✔ bus start runs until bus stop; status reports it while it runs (589.870543ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.306311ms)
✔ zoned uses the IANA zone across DST (12.414673ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (15.361146ms)
✔ two blocking decisions get two DMs with different nonces (12.226799ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.171141ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (10.006057ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (13.970726ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (15.636064ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (11.408405ms)
✔ 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 (99.487844ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (42.033245ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (12.180808ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (6.843471ms)
✔ an inbox read failure is logged and the next poll retries (0.566595ms)
✔ no Discord id reaches the journal or the log (7.102793ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (1.680487ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (1.967108ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.242875ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.399269ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.47903ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.788018ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.28774ms)
✔ 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.448896ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.329134ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.398665ms)
✔ digest content stays within Discord's 2000 characters (0.232068ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.778579ms)
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 (253.53672ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (29.875889ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2148.77842ms)
✔ busExit and refuseInsideAgent (0.531925ms)
ℹ tests 66
ℹ suites 0
ℹ pass 66
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2933.537466
@@ -0,0 +1,132 @@
✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (3.621501ms)
✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.239892ms)
✔ completed smoke replies and ordinary questions are idle, not human blockers (0.725294ms)
✔ only an explicit first-line input request makes a finished reply waiting (0.150697ms)
✔ tool activity, user text, errors and unfinished turns override attention text (0.122357ms)
✔ STOP access failure is unknown, not absence, under a non-root identity (34.557499ms)
✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (3.106752ms)
✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (1.34525ms)
✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (5.383925ms)
✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (0.560572ms)
✔ server rescans connector discovery and refuses HTTP reply without transport (30.899922ms)
✔ connector session links and linked directories are not read (1.012519ms)
✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (3.354834ms)
✔ CLI print uses the relaunch notice instead of old current preview (56.057003ms)
✔ connector owner and fixed task never inherit a native relaunch notice (1.818617ms)
✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (1.438209ms)
✔ loadConfig: missing file throws ConfigError (1.410362ms)
✔ loadConfig: invalid JSON throws ConfigError (0.27626ms)
✔ loadConfig: missing dataRoot throws ConfigError (0.206544ms)
✔ loadConfig: relative dataRoot throws ConfigError (0.198034ms)
✔ loadConfig: valid config returns dataRoot (0.604677ms)
✔ findNewestSession: picks the newest by mtime among two files (0.434068ms)
✔ findNewestSession: finds files in nested subdirectories (0.284813ms)
✔ findNewestSession: returns null for a missing dir (0.117471ms)
✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (0.587025ms)
✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (1.170303ms)
✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (0.419397ms)
✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (0.997913ms)
✔ deriveState: full state table (0.16249ms)
✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.304815ms)
✔ rule: newest entry is a tool result with no assistant text after it is working (0.260324ms)
✔ rule: a finished ordinary turn is idle, even if it says your move (0.245797ms)
✔ task: the first user message of the session, from text blocks (0.234725ms)
✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.237149ms)
✔ task: no user message in the log means null (shown as unknown), never a guess (0.240797ms)
✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.338475ms)
✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (0.465721ms)
✔ scan: the written record carries task, workspace and activeProject (0.455056ms)
✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (0.52772ms)
✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.347052ms)
✔ 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 (0.94347ms)
✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.392815ms)
✔ loadRegistrations: a missing seatsDir gives empty lists (0.129706ms)
✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (0.66035ms)
✔ 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.29492ms)
✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (0.630719ms)
✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.297576ms)
✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.514123ms)
✔ scanAgent: ageSeconds is computed from the injected now (0.253019ms)
✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.149203ms)
✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.315371ms)
✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.439033ms)
✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (0.987942ms)
✔ scan: relative boardDir throws ConfigError (0.098999ms)
✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (54.602612ms)
✔ CLI: missing config exits 2 with a refused: message (49.586258ms)
✔ CLI: unknown command exits 2 (48.912305ms)
✔ CLI: unknown --liveness value exits 2 (51.658356ms)
✔ panesRunPi: true when any trimmed line equals 'pi' (0.19844ms)
✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.069606ms)
✔ tmuxIsAlive: a pane running pi is alive (0.181262ms)
✔ tmuxIsAlive: session exists but pi has exited is not alive (0.074403ms)
✔ tmuxIsAlive: no such tmux session is not alive (0.079362ms)
✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.069174ms)
✔ tmuxIsAlive: passes -L <socket> only when a socket is given (0.098014ms)
✔ parsePanes: one pane per line, command and optional tab-separated path (0.078915ms)
✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.078816ms)
✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.095051ms)
✔ loadSeen: missing file returns {} (0.159643ms)
✔ loadSeen: invalid JSON throws ConfigError (0.188476ms)
✔ loadSeen: a JSON array throws ConfigError (0.153238ms)
✔ loadSeen: a non-string value throws ConfigError (0.185738ms)
✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.376136ms)
✔ markSeen: seen false deletes the key (0.286277ms)
✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.206712ms)
✔ markSeen: project containing '/' throws ConfigError (0.119844ms)
✔ markSeen: non-boolean seen throws ConfigError (0.114391ms)
✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.297461ms)
✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.222805ms)
✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.224168ms)
✔ scanAgent: an error-state session with a matching mark is seen (0.239582ms)
✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.530782ms)
✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.201505ms)
✔ 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.657081ms)
✔ 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.555664ms)
✔ 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 (1.135361ms)
✔ isLoopbackHost: recognizes loopback hosts (1.193759ms)
✔ isLoopbackHost: rejects non-loopback hosts (4.175629ms)
✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (2.931585ms)
✔ startServer: serves page, healthz, and a rescanning /api/board (34.632692ms)
✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (5.469118ms)
✔ startServer: /api/board returns 500 JSON with an error field when scan throws (2.396976ms)
✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (51.22562ms)
✔ CLI: serve rejects a non-numeric --port with exit 2 (50.883701ms)
✔ CLI: scan still works after the async cli refactor (54.813228ms)
✔ CLI: live serve prints its URL and answers /healthz (58.398192ms)
✔ page.html: esc() escapes every HTML-significant character (0.659275ms)
✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (8.07859ms)
✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (2.037422ms)
✔ POST /api/seen with invalid JSON returns 400 (2.835152ms)
✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (2.019713ms)
✔ POST /api/seen with a missing agent returns 400 (1.295617ms)
✔ POST /api/board returns 405; PUT /api/seen returns 405 (1.669646ms)
✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (50.449813ms)
✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.337992ms)
✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.270884ms)
✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.16532ms)
✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.13303ms)
✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.309982ms)
✔ 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.577047ms)
✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>: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 (28.948657ms)
✔ POST /api/reply: a registration with a tmux socket adds -L <socket> (25.686903ms)
✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (29.374986ms)
✔ 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 (14.264179ms)
✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (4.271884ms)
✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (0.155668ms)
✔ 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.584762ms)
✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (2.429038ms)
✔ 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.302417ms)
✔ 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 (7.946952ms)
✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (28.283933ms)
✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (5.489636ms)
✔ every refusal code the reader can raise has an HTTP status (2.556468ms)
✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (47.006525ms)
ℹ tests 124
ℹ suites 0
ℹ pass 124
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 616.847495
@@ -0,0 +1,160 @@
✔ W1: two processes acquire the same pair at once; exactly one claim (138.999793ms)
✔ W1: two writers publish the same revision at once: one wins, the other gets null, the winner's record stays (8.506643ms)
✔ W1: a revision name appears only after its bytes are synced; before that, only a temp file exists (3.652426ms)
✔ W2: acquire while a claim is reserved or active refuses already-active (139.65297ms)
✔ W3: acquire while stopping, uncertain, or stopped without proof refuses unsafe-replacement (208.781861ms)
✔ W4: same session with another seat tuple, and the reverse, both refuse; a loser on the seat key closes it no-unit (146.913673ms)
✔ W4: a hard link of one session under another seat is the same session: the second controller refuses already-active and launches nothing (16.758077ms)
✔ W4: a copy of one session under another seat is the same session: the second controller refuses already-active and launches nothing (15.265081ms)
✔ W4: a session header ID that changes after construction refuses target; nothing is claimed or launched (2.423659ms)
✔ W5: SIGKILL between every publication barrier of acquire and transition; restart never finds two holders or a lost claim (5564.882556ms)
✔ W5: SIGKILL between every publication barrier of release; restart finishes or holds the release (21290.183026ms)
✔ W6: controller killed mid-turn while the engine lives; restart is uncertain, no launch, prompts refuse (148.837324ms)
✔ W12: a live owner paused with SIGSTOP; a second controller refuses already-active and changes nothing (90.038212ms)
✔ W13: crash after the engine spawns, before active; restart finds the live unit: uncertain, no second spawn, force stop only (222.59503ms)
✔ W14: crash after reservation, before the spawn marker: stopped with a no-unit observation; the pair is free (180.796554ms)
✔ W20: crash after the spawn marker, scope collected; uncertain in both runs, the marker is copied, no launch until a boot proof (211.80975ms)
✔ W15: crash between the two keys during release; restart finishes it under the same claim ID (33.370238ms)
✔ W7: recorded boot ID differs on the same machine: stopped with a boot proof; open tool calls become uncertain (95.958842ms)
✔ W8: resume after a proven stop with the same pins: new claim ID, generation +1, same conversation, branch and leaf (31.22442ms)
✔ W9: resume with a changed binary, argv digest, branch or leaf is refused and the claim is unchanged (90.183997ms)
✔ W11: the controller writes no session file; only the fake engine's own appends appear (22.32039ms)
✔ W16: a highest revision that won't parse holds the pair uncertain; the older stopped revision is not reused (51.795196ms)
✔ W17: a claim root copied from another host refuses foreign-host and promotes nothing (57.941598ms)
✔ G1: a session path or claim root under .pi/state, ~/.claude, the data root or a registration refuses at construction (3.484511ms)
✔ G2: a symlink inside the fixture root to a live session file is refused by the real-path check (1.066388ms)
✔ G3: a fixture path swapped for a live path after construction is refused at bind (1.763114ms)
✔ K1: force stop kills a tool child that called setsid; stopped with a verified proof (2549.276526ms)
✔ K2: K1 on the process-group fallback ends uncertain, never stopped (144.547438ms)
✔ K3: SIGTERM acknowledged while a member lives: stopping until the kill phase, never stopped from TERM (2428.985502ms)
✔ K4: two engines; force stop one; the other survives by independent observation (4301.9598ms)
✔ K5: a stop during a tool call leaves the effect uncertain, and it is shown (2149.163268ms)
✔ K12: a member forking in a loop: the freeze stops it, enumeration is complete, populated 0 after cgroup.kill (2246.94703ms)
✔ K13: a member writing its pid into another cgroup is refused by the namespace; the kill is complete (2148.008577ms)
✔ K15: the shim gone, engine/cgroup.events unreadable, or the engine cgroup missing: evidence unavailable, not empty; uncertain (4339.87992ms)
✔ K10: controller killed between the TERM and kill phases: restart checks the invocation ID and re-runs from TERM for the same stop (385.636316ms)
✔ K11: controller killed after the confirmation is recorded, before TERM: restart checks the invocation ID and re-runs from TERM for the same stop (320.160393ms)
✔ K14: a unit with the recorded name but another invocation ID: evidence unavailable, no signals, uncertain (259.118506ms)
✔ K6: recover without proof, without confirmation, or with changed pins is refused (65.979593ms)
✔ K7: recover after proof, then launch: new claim and execution, generation +1, same leaf; the cancelled prompt is not replayed (30.958052ms)
✔ K8: an engine that loads another leaf on resume is refused before admission; it stays claimed until a proven stop (38.927223ms)
✔ K9: an interrupt that never settles stays uncertain; force stop stays available; takeover is refused while fenced (3025.311243ms)
✔ K16: a claim from another machine ID refuses foreign-host; no boot proof is issued (5.577284ms)
✔ K17: two launcher calls with one eligibility record: one launch, the other refuses, no second engine (31.074742ms)
✔ K18: the leaf changes after eligibility: launch refused; the reservation stays until released with proof (25.6351ms)
✔ S1: `/goal x`, with leading spaces or a tab, refuses text-policy at admission; zero engine bytes (32.684296ms)
✔ S2: every prefix pinned Pi interprets is refused, from the list the code uses; the rest reach the engine exactly (31.894859ms)
✔ S3: `/goal` on the second line is pinned from the source: Pi checks only index 0, so it is admitted and sent exactly (25.404556ms)
✔ S4: a `/` left in the composer is cleared when control transfers and returns; the next submit sends only the new text (41.567832ms)
✔ S5: an observer terminal gets a paste then Enter, as send-message.sh does: not admitted: controller, nothing sent (18.393873ms)
✔ S6: a mediated-shaped registration (no tmux) passed to the board's replyToRow: 409 no tmux session; exec never runs (0.681211ms)
✔ S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as one JSON string; the engine receives the exact text in one record (23.740901ms)
✔ P3: a Pi confirm, select, input or editor dialog is shown disabled with a reason and never answered (125.240068ms)
✔ E1: send, ack, user, toolCall, toolResult, final answer: shown once, no refresh, draft and reading position kept (40.541369ms)
✔ E2: U+2028, U+2029 inside JSON strings and CRLF line ends each parse as one record, on the splitter and through the controller (20.472634ms)
✔ E3: a multipart final, two blocks, null request correlation and duplicate delivery (30.998105ms)
✔ E4: a page read after message_end but before its entry is persisted: marker at the seam, re-read after run-settled, each message once (35.918921ms)
✔ E4: a gap or a new epoch also reconciles; nothing is concatenated across a gap (11.250458ms)
✔ E5: an unknown native event gives no client event; evidence records its type and bytes; the terminal count goes up (32.533965ms)
✔ E6: a tool result delayed across a pause and a reconnect is reconciled without a manual refresh (47.276026ms)
✔ E7: the terminal renders the same stream as the library client, as observer and then as controller, and submits only as controller (41.965175ms)
✔ terminal: engine control characters are made visible; a lost connection refuses submit (24.174742ms)
✔ terminal: outcome unknown is shown as such, with no resend offer, and nothing is resent (0.562867ms)
✔ terminal: text after Enter in the same input chunk starts the next message; it never joins the one submitted (0.2942ms)
✔ terminal: a paste-start marker split right after its ESC still opens the paste; the Enter inside it never submits (0.422497ms)
✔ terminal: invisible and bidi characters are made visible; head, status and notice lines stay one line (0.120017ms)
✔ every record these fixtures produced is a valid CHAT-01 record (E5: no record fails the schema) (332.391101ms)
✔ H1: two takeovers with the same expected generation: one wins, +1; the other refuses generation (54.806462ms)
✔ H2: the old controller's prompt after a takeover commits is refused with zero engine bytes (82.43351ms)
✔ H3: a takeover while a prompt holds the dispatch lock: written under the old actor, or refused; never both (132.498415ms)
✔ H4: self-takeover is refused (20.051491ms)
✔ H9: Interrupt racing a prompt's dispatch: before the write, dispatch-refused and no-turn; after, §3 rules (93.211892ms)
✔ H10: Interrupt and force stop together: one stop chain, force stop supersedes (87.129707ms)
✔ H10: an overlap during the pause before the abort: no abort, the stop ends uncertain (23.395035ms)
✔ H10: a no-turn Interrupt lifts only its own fence; admission stays closed under force stop, overlap or revocation (78.376918ms)
✔ H11: the controller disconnects mid-turn: work continues, the claim is unchanged, control stays put (129.210058ms)
✔ H12: an exact retry after reconnecting to the same incarnation returns the same receipt; one dispatch (14.867287ms)
✔ H13: a retry with the same request ID and different text is refused (15.797975ms)
✔ H14: late stdout from the old engine after a replacement is dropped by incarnation, counted, never rendered (140.500735ms)
✔ H15: a revoked connection's command is refused; the revocation fence holds (70.40603ms)
✔ H16: a second controller for the same session refuses already-active; the first is untouched (20.607613ms)
✔ H10: a second force stop while the first escalation runs refuses fenced; one escalation, and the claim records only the first stop's phases (57.752007ms)
✔ H17: a confirmation reused, answered from another connection, or used after the stop changed is refused (59.76822ms)
✔ H18: two prompts before any native output: the second refuses busy; one engine write (15.350836ms)
✔ H19: the pipe fails mid-line under a large prompt: delivery-unknown transport-unknown, poisoned, no later write (120.166377ms)
✔ H19: the link itself never writes again after an unknown outcome, whoever calls it (0.694913ms)
✔ H19: the controller dies mid-write of a large line: after restart the outcome is unknown and nothing is resent (466.178154ms)
✔ H20: the line is written but the ack is lost when the controller dies: orphan, outcome unknown, nothing resent (359.880623ms)
✔ H21: a retry of the exact request with the old token after a crash is stale-incarnation; no second write (319.856375ms)
✔ H22: after H21 and a valid recovery, a new request with the new token is admitted (2390.814685ms)
✔ H23: requests pending at a restart are not resent; each shows outcome unknown (461.597177ms)
✔ a plain conversation: catalogue row, one page, CHAT-01 records (6.495309ms)
✔ native entries map to blocks: tools, thinking, bash, notices, ids that do not fit (1.845716ms)
✔ F1: a malformed line is an unavailable part at its position, and reading continues (1.75288ms)
✔ F1: a missing parent stops the history with a notice that names the unreadable lines (2.618534ms)
✔ F1: an unreadable fork is never merged into another branch's history (1.689415ms)
✔ F1: a follow stays on its branch when the next entry's parent is unreadable (2.008519ms)
✔ F1: a file whose entries are all unreadable shows a notice per line (0.784078ms)
✔ F2: a truncated trailing line marks the view incomplete, not an error (1.223817ms)
✔ pagination: 100 parts, then the rest; parts concatenate to the whole branch (3.751945ms)
✔ F3: a replaced file (new inode) refuses old cursors with reconcile (4.317439ms)
✔ F4: a same-inode rewrite of the prefix refuses old cursors with reconcile (4.775366ms)
✔ F5: growth between pages keeps the epoch and the page stops at the pinned length (5.184904ms)
✔ F6: unknown, foreign and expired cursors refuse and leave the cursor usable (7.002224ms)
✔ F7: a symlinked file and a symlinked directory component are refused, never opened (8.106285ms)
✔ F8: a file swapped for a symlink after the catalogue is refused (2.045143ms)
✔ F9: registrations never add or redirect a root (1.483833ms)
✔ F10: a header cwd naming another project is refused (5.213162ms)
✔ F11: parentSession renders with a marker and the parent is never opened (1.019762ms)
✔ F12: two leaves: the default leaf is shown and the other branch reads alone (5.584279ms)
✔ F12: a follow refuses when an appended duplicate id changes the branch's earlier parts (3.432934ms)
✔ F12: a second root (Pi's resetLeaf) starts its own branch (1.517508ms)
✔ F13: compaction is a marker in place, then the retained content (0.908764ms)
✔ F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap (598.605287ms)
✔ fragments never cut a surrogate pair and keep an empty string (4.606857ms)
✔ F15: a Claude seat is an unsupported-harness placeholder whose directory is never read (1.428557ms)
✔ unknown conversations, empty files and non-Pi files refuse (2.335997ms)
✔ an unreadable file or root inside the roots is refused per row, not a failed catalogue (1.050082ms)
✔ a seat directory without search permission refuses that root, not the catalogue (1.859272ms)
✔ every page and cursor is a valid CHAT-01 record (875.578923ms)
✔ the engine pin holds for the installed package (2.598793ms)
✔ pinned Pi, sealed and without credentials, answers the controller's commands with the shapes the fake models (324.810483ms)
✔ pinned Pi appends thinking_level_change at start when the branch lacks one, so the leaf moves (K8 then fails closed) (296.978768ms)
✔ N25: ordinary Interrupt reconciles; a non-empty queue_update in the window is O5 (74.83861ms)
✔ N1: an extension's follow-up queued after the fence is cleared before any abort; O5, Unknown (54.1458ms)
✔ N1: a follow-up queued before the fence is O5 at once; the Interrupt refuses fenced (22.98321ms)
✔ N2: with abort first, the fake runs the external item (the ordering guard has teeth) (22.268534ms)
✔ N3: the fence lands in preflight, preflight errors, no run: failed, No run, uncertain (36.45886ms)
✔ N4: the ack arrives after the first abort and a run starts: clear and abort again; Interrupted (21.68886ms)
✔ N5: an input handler takes the prompt: ack, no run, delivery-unknown handled-without-run (116.161204ms)
✔ N6: an extension queues between clear_queue and abort: O5 and O6, Unknown (50.613061ms)
✔ N7: clear_queue times out: no abort, nativeQueue unknown, force stop still ends it (1533.053947ms)
✔ N7: clear_queue answers an error: no abort, nativeQueue unknown, the link not poisoned (13.930099ms)
✔ N8: an extension prompt starts a run during Mosaic preflight; the losing settle is O3 (70.710695ms)
✔ N9: a run that started before the fence and ends aborted: failed interrupted, Interrupted (13.637596ms)
✔ N9: decision 34: a run that ends aborted with no stop in progress: aborted-without-stop, uncertain, outcome unknown (14.878805ms)
✔ N9: an aborted that lands after the fence but before any abort is written: aborted-without-stop, Unknown (31.802435ms)
✔ N10: fake conformance (33.058444ms)
✔ N11: the run fails before any user message_start: delivery-unknown ack-without-start, never failed (29.244906ms)
✔ N12: input that starts a run after the final empty clear is O1 and not part of the stop's proof (20.118833ms)
✔ N13: agent_start with no slot held is O1; a later prompt refuses with zero engine bytes (64.60306ms)
✔ N14: the run completes while clear_queue is in flight: finished, Completed first, uncertain (29.196526ms)
✔ N14: the run completes after the abort is written, before Pi applies it: finished, never relabelled (26.842034ms)
✔ N15: the fence lands in preflight, then an input handler takes it: handled-without-run, No run (22.453252ms)
✔ N16: Interrupt with no slot and no run refuses no-turn: no stop, no bytes, admission open (12.680362ms)
✔ N17: the run fails on its own during the exchange: failed, Failed on its own (28.392304ms)
✔ N18: no final assistant message_end, or a lost line: working stays working; before working, transport-unknown (105.211014ms)
✔ N19: a losing extension prompt settles inside the Mosaic run before its user message: O3, run-overlap (137.549503ms)
✔ N20: an extension triggerTurn during Mosaic preflight starts first; while streaming it queues with no signal (88.798093ms)
✔ N21: a losing settle after the receipt settled finished is O2; the receipt stays finished (17.677504ms)
✔ N22: an agent-level custom message is dropped by the clear with no signal; evidence names the seal (13.362159ms)
✔ N23: a nextTurn message survives clear and abort and attaches to the next prompt, with no signal (14.431043ms)
✔ N24: the seal is an allow-list: --extension, a missing --no-* flag, a second --mode or --session, a session or output flag, or a stray word refuses unsealed-engine; no engine starts (37.309547ms)
ℹ tests 152
ℹ suites 0
ℹ pass 152
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 30796.131987
@@ -0,0 +1,186 @@
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (2.903913ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.454462ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.78376ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.466664ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.513829ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.416436ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (6.819423ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.289907ms)
✔ authorize: open channel, listed user (2.310838ms)
✔ authorize: wrong guild (0.217528ms)
✔ authorize: no guild (DM) (0.836277ms)
✔ authorize: unlisted channel (0.187908ms)
✔ authorize: unknown channel, no info (0.193527ms)
✔ authorize: thread of listed parent (0.188433ms)
✔ authorize: thread of unlisted parent (0.179334ms)
✔ authorize: text channel that is not a thread and not listed (0.144764ms)
✔ authorize: unlisted user (0.911939ms)
✔ authorize: no author (0.534731ms)
✔ authorize: bot author (listed id, bot flag) (0.146689ms)
✔ authorize: system author (0.115937ms)
✔ authorize: the bot itself (0.11845ms)
✔ authorize: webhook (0.092028ms)
✔ authorize: mention channel without mention (0.125109ms)
✔ authorize: mention channel with bot mention (0.127117ms)
✔ authorize: mention channel with @everyone only (0.101821ms)
✔ authorize: mention channel mentioning someone else (0.163548ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.109588ms)
✔ authorize: private thread under mention channel, mentioned (0.088943ms)
✔ authorize: private thread under mention channel, not mentioned (0.068659ms)
✔ authorize: thread in another guild per channel info (0.073402ms)
✔ authorize: not an object (0.058343ms)
✔ authorize: no id (0.061006ms)
✔ authorize: oversize content is accepted and flagged (0.068543ms)
✔ authorize: exactly the limit is not oversize (0.064352ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.438614ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.142735ms)
✔ binding: a complete binding validates and is frozen (2.739096ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.234553ms)
✔ binding: empty allowlists refuse (0.404158ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.2463ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.966692ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.087639ms)
✔ binding: file must be 0600, regular, not a symlink (1.8907ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.257554ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (85.300078ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (4.469484ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (302.831421ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (201.036945ms)
✔ cli: run refuses when STOP is present, before any network use (143.365084ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.880658ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.418763ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.920871ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (12.913425ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.477812ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.129505ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.251699ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.479765ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.370296ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (2.673987ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.049364ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (0.633413ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (40.210132ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.715359ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (5.397667ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (3.75225ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.232436ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (3.891573ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.084563ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.457993ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (1.039986ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.343573ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (3.663203ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.06878ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (3.810866ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.269622ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (1.370235ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.793196ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.19291ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.351993ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.123168ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.634407ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.429101ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.391398ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (53.446831ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (37.021802ms)
✔ engine: one prompt, one turn, text and usage come back (29.426679ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (334.474382ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (234.697449ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (103.829241ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (230.393176ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (127.923407ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.349312ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.508555ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.459239ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.530617ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (430.037768ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.965826ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (43.019075ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.435203ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.590545ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.577269ms)
✔ gateway: op 9 resumable resumes (0.365212ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.924753ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.575025ms)
✔ gateway: close() is final and unparseable frames are ignored (0.554273ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (68.40222ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (35.509171ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (63.337673ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.268017ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (70.998308ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (79.601099ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (88.922832ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (192.561515ms)
✔ git: push pushes the named branch only and reports up to date (70.937641ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (109.73177ms)
✔ git: the credential helper answers get over https from a private file and nothing else (188.202912ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (713.795919ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.865057ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (69.910142ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (0.703285ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (2.57031ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (34.852011ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (278.3905ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.422817ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (23.179621ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.995738ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (36.881048ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (143.185142ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (85.098925ms)
✔ notices: a kind is recorded per UTC day and found again (0.507347ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.001579ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.379525ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.684432ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (40.714778ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (32.096076ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (30.979192ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (68.06152ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (660.288689ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.514157ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.949474ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.716796ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.867047ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.117036ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.069278ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.897525ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.593861ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.103284ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (19.607646ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (7.030442ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (6.646527ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (2.898618ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.530955ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (1.757769ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1010.059937ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.418211ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.24409ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.188286ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.190836ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.232401ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (2.757129ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (2.584477ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (3.375799ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.125719ms)
✔ tools: listing and search caps hold (7.523794ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.589112ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (3.598801ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (0.817416ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (0.84635ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (3.75086ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (1.844748ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.243029ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (2.615671ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.494227ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1021.780474ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.288246ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.212091ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.584111ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.198462ms)
ℹ tests 178
ℹ suites 0
ℹ pass 178
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2592.580644
@@ -0,0 +1,86 @@
✔ a raw token file, with or without one trailing newline, reaches curl only through the config stream (118.594239ms)
✔ the raw path accepts nothing else, and refuses before curl runs (367.73863ms)
✔ the file checks still apply on the raw path: mode, symlink, missing, directory (277.674018ms)
✔ the raw path base URL has no override (52.543854ms)
✔ the JSON path is unchanged, and JSON never falls through to the raw path (244.478185ms)
✔ a file that changes between the two reads refuses before curl runs, with or without a body (711.198471ms)
✔ the token reaches no child environment, even with an inherited CFG or SHELLOPTS=allexport (672.287612ms)
✔ real helper GET HTTP 200 preserves exit 0 without credentials (21.392231ms)
✔ real helper POST HTTP 201 preserves exit 0 without credentials (10.999711ms)
✔ real helper GET HTTP 403 preserves exit 1 without credentials (8.244951ms)
✔ fixture git subjects only, follow-ups and three session kinds (105.850533ms)
✔ text and JSON carry same numbers, open and truncated title (169.021156ms)
✔ missing credentials exit 2, no-issues never calls API and shows unknown (134.774246ms)
✔ empty range gives no rows and zero totals (96.267627ms)
✔ inclusive UTC dates, first-line preamble only, role and seat boundaries (101.481753ms)
✔ close-only issue included, even median, missing metadata stays unknown (93.922638ms)
✔ unique commits but per-issue links count multiple tags once each (101.841089ms)
✔ page cap refuses rather than silently undercounting (87.159091ms)
✔ bad API payload not JSON refuses (89.245891ms)
✔ bad API payload {} refuses (87.247327ms)
✔ bad API payload [{"number":1}] refuses (82.516885ms)
✔ partial or malformed session log refuses with location, not content (94.357432ms)
✔ a U+2028 or U+2029 inside a session string is one line, not a malformed record (99.882598ms)
✔ no sessions is an empty table; symlink source refuses (163.470236ms)
✔ reads only refactor even when another branch is checked out (98.041163ms)
✔ invalid dates, reverse dates and duplicate options refuse (58.6285ms)
✔ T3 agent assignments do not count as human in Table 2 (95.217595ms)
✔ preamble parsing and issue number boundaries (0.46154ms)
✔ T3 header: agent, or board from control-board; anything short of the full header is human (0.141378ms)
✔ no closed issues with human messages means undefined ratio, not invented zero (0.197281ms)
✔ T3: seat, archived, unmapped and Researcher threads count; imported, deleted and other-project threads do not (185.160035ms)
✔ T3: the default path is read from HOME and prints no path line; --no-t3 says so (399.779566ms)
✔ T3: a HOME with no database exits 1 and names --no-t3 (90.585884ms)
✔ T3: a file that is not a database exits 1 and names --no-t3 (95.28352ms)
✔ T3: a seat thread renamed to another seat exits 1 naming thread, title and roles (113.166308ms)
✔ T3: an unmapped thread addressed as a seat exits 1 (111.036626ms)
✔ T3: a header to another thread id is not cross-checked (114.716086ms)
✔ T3: no project, or two, for this root exits 1 (290.567987ms)
✔ T3: a removed column exits 1 and names it (101.565435ms)
✔ T3: a missing table exits 1 and names it (99.926989ms)
✔ T3: a counted row with an unknown role exits 1 without its text (111.131468ms)
✔ T3: a counted row with non-text content exits 1 without its text (116.180417ms)
✔ T3: a counted row with an unparseable created_at exits 1 without its text (110.968807ms)
✔ T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts (197.389671ms)
✔ T3: a human message with no event counts in humanWithoutEvent (113.950005ms)
✔ T3: an unparseable event makes the diagnostic unknown and keeps the counts (204.679915ms)
✔ T3: an event with no string messageId makes the diagnostic unknown and keeps the counts (213.732484ms)
✔ T3: an error that is not from SQLite is rethrown, not reported as a database failure (59.38188ms)
✔ T3: a symlink at ~/.t3 exits 1 (83.748007ms)
✔ T3: a symlink at ~/.t3/userdata exits 1 (87.789686ms)
✔ T3: a symlink at ~/.t3/userdata/state.sqlite exits 1 (83.319008ms)
✔ T3: with --t3-db, a symlinked file or directory exits 1 (170.291468ms)
✔ T3 WAL: the newest message only in -wal, writer attached, is counted (118.18355ms)
✔ T3 WAL: stopped cleanly, counts are correct and the main file is unchanged (119.273698ms)
✔ T3 WAL: -wal without -shm in a writable directory is read (138.87176ms)
✔ T3 WAL: -wal without -shm in a read-only directory exits 1 (132.838681ms)
✔ T3 WAL: stopped cleanly in a read-only directory exits 1 (118.205917ms)
✔ T3: a lock held past the 5 s busy timeout exits 1 and names --no-t3 (5120.326126ms)
✔ a done row whose closing issue is open is a violation; a row that is not done is not (2.500601ms)
✔ an issue several rows close is expected closed only once all of them are done (0.73534ms)
✔ closure needs positive evidence: unknown is undecided, and so is a skipped or short issue check (0.53954ms)
✔ each owner of an in-progress or in-review row gets one liveness class (13.651359ms)
✔ a required row not done after 14 days is a violation; a legacy row uses genesis as its lower bound (0.587481ms)
✔ an ISO requiredSince, as `set required` writes it, ages from its UTC day; one that does not parse is a violation (0.315708ms)
✔ the text section always ends in a count and a result, and never prints a full pass (0.480685ms)
✔ pidAlive: a running pid is present, an exited one is gone, and EPERM still means present (23.386605ms)
✔ issue states: open list first, then the metric page, then at most 10 lookups (284.666989ms)
✔ a full open list: lookups settle what it leaves out, and only an unsettled issue keeps it undecided (365.749797ms)
✔ the open list refuses on a failed call or a bad record, and never echoes the helper (169.211568ms)
✔ a helper call past the deadline is killed with its child, and the call reports it (2007.38853ms)
✔ readQueue loads queue.json through the queue validator and refuses anything else (91.484931ms)
✔ protected changes list every in-range entry that changes a required or parked row (233.342814ms)
✔ the CLI prints the queue section above the weekly table and under a queue key in --json (486.462843ms)
✔ a queue with nothing wrong prints 0 violations and a reduced pass, never a full pass (199.386399ms)
✔ --no-issues makes no call and leaves the issue checks undecided; --no-queue skips the section (258.105562ms)
✔ the CLI refuses a bad queue before any call, and a failed open list with exit 2 (234.925552ms)
✔ --unsupported-runtime repeats once per seat and takes a seat name (301.420891ms)
✔ an unreadable config makes every owner invalid instead of passing them (124.051793ms)
ℹ tests 78
ℹ suites 0
ℹ pass 78
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10715.167538
@@ -0,0 +1,77 @@
✔ pure resolution selects current default or explicit enrolled account (1.919587ms)
✔ scope is explicit, bounded and never inferred (1.579855ms)
✔ fork pin is preserved against default change, override, missing account and revocation (0.710634ms)
✔ unenrolled account/provider, missing harness, model expansion and native model ceiling refuse (0.798155ms)
✔ only explicit synthetic credential forms and internal fixture stores admitted (5.446372ms)
✔ two concurrent workspaces of the same agent publish distinct complete private generations (58.573612ms)
✔ same execution ID is exclusively claimed and cannot overwrite a published generation (36.350298ms)
✔ failed generation after-auth preserves prior files, records failure and refuses blind same-ID retry (45.854624ms)
✔ failed generation before-publish preserves prior files, records failure and refuses blind same-ID retry (55.323414ms)
✔ credential lock contention refuses without duplicate side effects (11.347744ms)
✔ symlinked pre-existing final target is refused and never followed (31.749281ms)
✔ invalid registry cannot resolve; no fallback to supplied partial entries (0.398185ms)
✔ post-publication failure records uncertainty, preserves complete generation and prevents replay (26.197757ms)
✔ expired credentials refresh under transaction and subsequent generation reuses rotation (85.726793ms)
✔ refresh failure retains prior generation and store state (67.795926ms)
✔ refresh timeout retains prior generation and store state (147.743057ms)
✔ refresh malformed retains prior generation and store state (65.246806ms)
✔ concurrent refresh on same account refuses contention while unrelated account proceeds (222.470507ms)
✔ invalid refresh options refuse before burning claim (38.452168ms)
✔ fixed fake process rotates both OAuth fields without mutating caller input (35.609313ms)
✔ concurrent isolated processes preserve separate provider credentials (26.763497ms)
✔ fake failure is refused with fixed diagnostics (28.24135ms)
✔ fake malformed is refused with fixed diagnostics (23.593098ms)
✔ fake timeout is refused with fixed diagnostics (103.490693ms)
✔ fake unchanged is refused with fixed diagnostics (25.131315ms)
✔ caller executable/environment injection is rejected before spawning (0.300073ms)
✔ valid fixture tree validates and lists without secrets (68.081332ms)
✔ unknown-field refuses (0.371759ms)
✔ invalid-id refuses uppercase and traversal shapes (0.268678ms)
✔ plain-http baseUrl requires allowInsecureTransport (0.296086ms)
✔ native provider rejects allowInsecureTransport (0.140857ms)
✔ unsupported credential type and kind refuse (0.147129ms)
✔ account provider-path mismatch refuses (0.10235ms)
✔ profile account refs must be provider/account shaped (0.625078ms)
✔ seat selection accepts fork pin field, validates account refs (0.217339ms)
✔ harness manifest id must equal executable (gate 1) (0.19595ms)
✔ CLI validate: duplicate provider id across files refuses (28.672577ms)
✔ CLI validate: missing referenced provider/account refuse (32.402305ms)
✔ CLI validate: broken JSON refuses without secret echo (28.101555ms)
✔ CLI usage errors exit 2 (55.542875ms)
✔ credential.json sibling presence does not break validation and is never read (65.193222ms)
✔ D1 missing, empty and structurally empty roots refuse, no list projection (168.473897ms)
✔ D1 required directory auth cannot be absent (53.820205ms)
✔ D1 required directory auth/providers cannot be absent (55.518075ms)
✔ D1 required directory auth/accounts cannot be absent (62.62363ms)
✔ D1 required directory auth/settings cannot be absent (68.901345ms)
✔ D1 required directory harnesses cannot be absent (62.781661ms)
✔ D1 root file and unreadable metadata refuse (113.227159ms)
✔ D2 no symlink traversal at auth/providers/openai-codex.json (56.362661ms)
✔ D2 no symlink traversal at auth/accounts/openai-codex/homelab-openai (61.353629ms)
✔ D2 no symlink traversal at auth/providers (53.495206ms)
✔ D2 no symlink traversal at auth (53.821032ms)
✔ D2 root and ancestor symlinks and lexical traversal refuse (162.253987ms)
✔ private filesystem modes enforced for root (52.511966ms)
✔ private filesystem modes enforced for auth (56.016871ms)
✔ private filesystem modes enforced for auth/providers/openai-codex.json (55.381156ms)
✔ private filesystem modes enforced for auth/accounts/openai-codex/homelab-openai/account.json (62.616506ms)
✔ D3 numeric version 1 only across all record kinds (1.233148ms)
✔ D4 nested unknown keys and missing per-kind required fields refuse (60.640569ms)
✔ D5 unenrolled default refuses even when account exists (64.638105ms)
✔ D6 provider/account credential type must match (62.814507ms)
✔ D7 every harness endpoint enforces HTTP opt-in and shape (0.437418ms)
✔ D8 URLs reject embedded credentials and unsupported protocols without echo (167.695284ms)
✔ D9 malformed JSON diagnostics contain no content excerpt (61.482042ms)
✔ D10 missing metadata is missing-path, not invalid-json (62.199152ms)
✔ D10 library returns no partial entries on any invalid record (74.715426ms)
✔ null/scalar/array metadata refuses without stack or echo (249.850278ms)
✔ credential sibling is never opened, even when an unreadable symlink (32.363117ms)
✔ oversized metadata refuses before parsing (54.461836ms)
ℹ tests 69
ℹ suites 0
ℹ pass 69
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2143.653413
@@ -0,0 +1,158 @@
✔ F1: an ordinary commit after update-ref is refused until step 8; then it commits and the queue stays at C (1123.09366ms)
✔ F1: a plain `commit -e` whose guard ran before update-ref fails at its own HEAD update (906.626911ms)
ℹ git commit -e: index.lock free during the editor
✔ F1: a `commit -e -- path` whose guard ran before update-ref fails at its own HEAD update (878.906969ms)
ℹ git commit -e -- src.txt: index.lock held during the editor
✔ F1: step 8 with index.lock held exits 3, and ordinary commits stay refused until the printed command runs (961.495951ms)
✔ F1: HEAD moving after the step-7 guard check and before update-ref: refused, nothing published (958.097988ms)
✔ F1: H is recorded before the canary, so HEAD moving during the step-1 canary is refused at step 7 (898.953729ms)
✔ F1: a queue commit landing after H is recorded: step 1 says HEAD moved, not the guard (1186.759984ms)
✔ F1: a queue commit landing between the HEAD check and the canary: the failed clean run is reported as HEAD moved (793.011208ms)
✔ F1: a shared-index change during the procedure is not committed (912.048115ms)
✔ F1: a queue path staged after update-ref: step 8 stops and touches nothing (893.352413ms)
✔ F1: a missing or a different hook refuses (623.462627ms)
✔ F1: same bytes without the exec bit, a symlinked hook, and core.hooksPath in the local or global scope each refuse before update-ref (1085.782739ms)
✔ F1: the canary refuses a hook that git would not run (528.831803ms)
✔ F1: the guard deactivated after step 1 is refused at the step-7 recheck (806.638181ms)
✔ bootstrap: implementation-only HEAD, the guard, genesis, the --genesis commit, then an extending commit (773.656262ms)
✔ bootstrap: --genesis with a base present, no base without --genesis, an op before the first commit, a changed map, another branch (813.111045ms)
✔ bootstrap: the archived tests and validator run outside any repository (773.607392ms)
✔ general: an unrelated staged file stays staged, and the committed blobs are the snapshot bytes (752.831693ms)
✔ general: a queue write after the snapshot is not committed (1090.50174ms)
✔ general: a snapshot whose log does not extend the base refuses (812.53443ms)
✔ general: install-hook privilege, repair of a same-bytes hook, and its refusals (162.956804ms)
✔ general: environment overrides, a linked worktree and usage (504.473454ms)
✔ general: a queue path staged before the run refuses at step 1 (554.652009ms)
✔ general: HEAD's queue tests failing in the archive refuse (745.828019ms)
✔ genesis document serializes deterministically and replays (4.087765ms)
✔ a hand edit that stays valid JSON fails replay; a formatting-only edit fails re-serialization (2.733583ms)
✔ a tampered result, receipt or viewSha fails replay (2.516496ms)
✔ op ids: 8 to 72 characters for callers, 80 in the log for .outcome entries (0.199785ms)
✔ add: defaults for an ordinary seat, privileged extras, refusals (3.594784ms)
✔ matrix: queued→briefed privileged; briefed→in-progress owner with after satisfied (4.995694ms)
✔ matrix: release, review round, changes requested and waiting-on-jason (11.698919ms)
✔ matrix J5: in-review→done by the gate owner with evidence naming the current round (7.40716ms)
✔ review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it (18.043572ms)
✔ the row schema refuses a round with a null issue, and the A1 review shape (P2) (2.375591ms)
✔ matrix R1: every state × target × actor class matches 8.7, gate owner jason or not, required or not (1327.424727ms)
✔ matrix: blocked keeps the claim and returns only to previousState (4.179038ms)
✔ matrix J4: parking is Jason's, refused while required; unpark returns to queued (2.796213ms)
✔ field edits: who may change what (5.608282ms)
✔ set issues keeps a logged narrowing of closes (N10) (2.806362ms)
✔ text the table shows refuses \ and <, everywhere it enters (N8) (1.925822ms)
✔ every accepted text renders to nine cells on every row (N8) (22.358458ms)
✔ genesis: the map refuses an owner among its row's reviewers; replay doesn't (2026-10-04) (0.497087ms)
✔ times and dates must be calendar values, not just the shape (2026-10-04) (1.624743ms)
✔ replay holds every op id to the caller's rule (N11) (4.934225ms)
✔ note: owner, listed reviewer or privileged; empty clears (1.171347ms)
✔ assign moves the claim with the owner; done clears it (2.628432ms)
✔ render is byte-stable and escapes pipes (0.766776ms)
✔ view classification: current, genuine stale, edited stale marker, changed current body, markers (0.621348ms)
✔ next: resume, then review, then start, then wait, then nothing; lowest id first (18.560244ms)
✔ canonical args make a retry's identity independent of list order (0.303241ms)
✔ manifests, headings and blob ids (0.414845ms)
✔ the migration map: one queue-map block, exact keys (0.702117ms)
✔ every call but `queue` reaches the seat CLI exactly as before A2 (604.612956ms)
✔ `queue` reaches the queue CLI with the rest of the arguments (194.810538ms)
✔ the pre-A2 fixture is the script A2 changed (0.301444ms)
✔ acquire publishes the record by link; release removes only its own lock (4.229467ms)
✔ a kill between the temp write and the link leaves no lock (36.935103ms)
✔ a short or failed temp write refuses and leaves no lock and no temp (1.871304ms)
✔ a link error other than EEXIST refuses (1.181228ms)
✔ an error after the link releases the lock: unreadable gate, failing temp stat (2.907727ms)
✔ a release that fails on a gate path is reported, never a stack trace (P1) (4.290814ms)
✔ a paused holder: another writer waits 10 s, then refuses naming it live (10059.982969ms)
✔ two concurrent unlockers: the second refuses on the gate (22.476194ms)
✔ a writer publishing during an unlock, lock first: unlock sees it live and refuses (1.927059ms)
✔ a writer publishing during an unlock, gate first: the writer releases and refuses (1.722825ms)
✔ a gate swapped while held is left in place and reported, on success and on refusal (N1) (1.977909ms)
✔ a reused pid within one boot is mismatch; unlock removes the lock and never signals the process (3.383035ms)
✔ the same pid and start on a different boot is mismatch (0.741045ms)
✔ a foreign host is unknown whatever the local pid says; unlock refuses (46.957194ms)
✔ unreadable /proc: classification is unknown and acquire refuses (0.625757ms)
✔ invalid records: empty, unparsable, wrong keys, bad start or boot (0.23317ms)
✔ a stale gate blocks writers; --check-gate says mismatch for a reused pid (3.57997ms)
✔ a delayed release by a dead owner, after unlock and a new owner: the inode check keeps the new lock (2.147497ms)
✔ release checks the inode too: a byte-identical lock file with a new inode is left in place (1.012782ms)
✔ unlock refuses a live, unknown or invalid lock, and does nothing without one (3.955126ms)
✔ the migration map validates and renders the golden genesis table (3.302219ms)
✔ the marked QUEUE.md holds every row and parked item between its markers (1.156618ms)
✔ map-check reports each kind of drift (4.654279ms)
✔ a request posts once as the requester; a retry sends nothing (589.349761ms)
✔ each transport answer maps to posted, failed or uncertain (8.9 step 3) (2965.786909ms)
✔ the pre-send checks: GET user must name the requester, under the deadline (942.495573ms)
✔ the lead's request refuses a token for login sage (502.310836ms)
✔ the credential file: the seat's own, 0600, no symlink, never the shared default (429.46681ms)
✔ an unresolved request blocks a new request, a new round, waiting-on-jason and done (1329.366392ms)
✔ a same-op retry after a kill sends nothing, even with a stale view (2033.657318ms)
✔ a held lock at the outcome exits 3 and names what the transport said (534.566132ms)
✔ late outcomes: after an abandon, and after a resolve with the same or another id (1367.135232ms)
✔ resolve checks the comment: issue, markers, round, candidate and author (1390.301985ms)
✔ the lead resolves a seat's request: the comment's author is the requester, fetched with the lead's token (462.37746ms)
✔ validateRow checks a request round's shape, which every replayed entry must keep (310.297933ms)
✔ request, changes, a new candidate, approval: every round pinned; no review files (1268.058829ms)
✔ a row with no reviewers opens a round that posts nothing (748.981551ms)
✔ verify-commit: a prospective tree must hold exactly the candidate's paths (1054.353712ms)
✔ semantics: v1 entries replay as before; review entries need v2 (309.071342ms)
✔ set reviewers refuses the row's owner (2026-09-28) (240.118293ms)
✔ the owner records no verdict, even as a listed reviewer (311.114905ms)
✔ a request comment over the length limit is not sent (351.569715ms)
✔ a late POST on a closed row leaves a conflict nothing can resolve, and resolve asks nothing (356.668232ms)
✔ a Jason-gated row reaches waiting-on-jason only on every reviewer's approval (1642.075608ms)
✔ genesis: refusals before anything is written (440.209638ms)
✔ genesis: the map must be committed, well formed, with committed briefs and seats (552.297366ms)
✔ genesis: markers, a stray witness, once only; a retry returns the receipt (463.823172ms)
✔ genesis: a file holding genesis alone with no witness is confirmed by sync or a retry (279.997243ms)
✔ canonical checks: worktree, second clone, detached HEAD, wrong branch, GIT_DIR, foreign code; a symlink works (570.928229ms)
✔ --by that differs from MOSAIC_AGENT_NAME warns on stderr and logs nothing more (N12) (480.71357ms)
✔ op ids: missing, too long, reserved; a retry answers; another payload refuses (567.401069ms)
✔ a retried add returns the id it first allocated, after reassignment and after done (589.036439ms)
✔ Rocko's S4 schedule: a lost result, another writer, then the retry opens no second round (419.946608ms)
✔ the review issue and the evidence round through the CLI (lead decision 23, 8.7) (783.814932ms)
✔ claims and add defaults through the CLI; candidates are manifests or reachable commits (575.769966ms)
✔ add, set reviewers and assign refuse the row's owner as a reviewer (330.238827ms)
✔ the working-brief check: a changed working copy refuses the start and flags next (512.003779ms)
✔ next: resume first, then nothing for an idle seat; needs a seat (264.440746ms)
✔ view stale: new ops and verify refuse naming the unshown op; retries answer; reads warn; render fixes (508.26713ms)
✔ view unknown: a hand edit, an old marker over an edited body, missing or duplicate markers (928.114596ms)
✔ a hand edit to queue.json refuses every verb, reads included (482.195771ms)
✔ verify and render --check leave bytes and mtimes unchanged (358.437948ms)
✔ render is byte-stable across runs and repositories (209.006408ms)
✔ snapshot and verify --snapshot (616.971903ms)
✔ usage errors exit 4 (517.000537ms)
✔ a short write, ENOSPC or a file fsync failure: nothing visible, temp removed (161.525767ms)
✔ a rename failure: nothing visible, temp removed (130.615185ms)
✔ a directory fsync failure: uncertain, exit 3, no receipt; the tail refuses new ops; a retry confirms (153.433215ms)
✔ a directory fsync failure, then sync names the op (271.248573ms)
✔ a witness write failure: uncertain, durable, exit 3; the view is untouched (138.332545ms)
✔ the .git fsync after the witness rename fails: uncertain, exit 3, the witness says so (122.960464ms)
✔ confirming a tail fsyncs queue.json and docs/plans before the witness; either failure changes nothing (131.07291ms)
✔ the docs/plans fsync after the view rename fails: the op stands, the view is written, a warning says so (109.887142ms)
✔ a lock swapped while held is left in place and reported, on a receipt and on a refusal (120.207877ms)
✔ a lock that cannot be released after an op is reported, on a receipt and on a refusal (130.250122ms)
✔ unlock prints a swapped gate's warning on stderr, the result on stdout (104.778697ms)
✔ a view write that fails keeps the op and reports a stale view (105.263596ms)
✔ SIGKILL before the rename: nothing recorded; the retry removes the leftover temp (544.001901ms)
✔ SIGKILL after the rename, before the witness: the tail refuses new ops and sync names the op (610.622764ms)
✔ SIGKILL after the witness, before the view: the stale refusal names the op (569.060997ms)
✔ SIGKILL after the view, before the receipt: the retry returns the receipt (574.825452ms)
✔ git checkout between steps 1 and 7: step 7 refuses and nothing is written (132.228675ms)
✔ git stash restoring an older valid pair: history lost; accept-history needs privilege, a reason and --yes (782.093087ms)
✔ a deleted witness: refused after the locked recheck; accept-history records it absent (169.363828ms)
✔ a header edit during a write: the op stands, the view write is skipped with a warning (108.565309ms)
✔ a reader paused between the witness and the file while a writer finishes: no lost-history report (117.868726ms)
✔ file-then-witness order forced by a hook: the locked recheck prevents a false report (120.002769ms)
✔ a writer paused before and after the witness rename: readers see a tail, then a match (117.337284ms)
✔ a true rollback is reported only after the locked recheck; a held lock names its holder instead (472.231055ms)
✔ an accept-history in progress: an unlocked reader waits on the lock and never reports lost history (433.987458ms)
✔ the platform check refuses other filesystems (97.893225ms)
✔ tmpfs passes only a test layer that allows it (N5) (126.487748ms)
✔ unlock keeps a multi-line lock record on stdout (P3) (149.811729ms)
ℹ tests 148
ℹ suites 0
ℹ pass 148
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 19601.279263
@@ -0,0 +1,27 @@
✔ resolveSeat: by name under --repo resolves the repo layout (1.201331ms)
✔ resolveSeat: by path resolves the fleet layout (0.336482ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.70358ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.63805ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.787247ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.280028ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.097861ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (29.046665ms)
✔ CLI launch: --harness lands in the record (28.749652ms)
✔ CLI launch: the launch script's own exit code passes through (27.528268ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (58.113927ms)
✔ CLI launch: omitting --task records an empty string, not null (30.100879ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (78.310326ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (135.586594ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.371918ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.564905ms)
✔ validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change (0.694039ms)
✔ updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution (0.860769ms)
✔ CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte (264.773945ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 711.455044
@@ -0,0 +1,59 @@
✔ the boot config is checked before anything starts (13.525058ms)
✔ a business with no tracker entry refuses task verbs (11.657263ms)
✔ credential.expiring and .expired are recorded once per instance (28.491107ms)
✔ a token file that changes on disk records credential.changed (12.173554ms)
✔ autostart polls, reconciles and retries a startup the tracker was down for (35.116846ms)
✔ a refusal a restart must clear is not retried by the poll (7.934045ms)
✔ a poll that fires while two are queued is dropped (12.221434ms)
✔ close waits for a running verb and refuses one that has not started (40.109683ms)
✔ the bundled Vikunja is the pinned upstream image the runbook names (0.854356ms)
✔ every published port is on 127.0.0.1, and no secret is in the file (0.320283ms)
✔ the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent (89.076359ms)
✔ the recorded task bodies pass the checks S3 applies to every read (0.496214ms)
✔ the client works against the fake over real HTTP with the platform fetch (93.784381ms)
✔ a correct install starts, and the first reconcile records tasks that already exist (28.196029ms)
✔ verbs refuse while a business is starting and after startup refused it (15.096596ms)
✔ startup refuses a token that can do more than its role needs (35.919401ms)
✔ startup refuses an unsupported version and flags an untested one (25.521099ms)
✔ startup refuses a board that the runbook did not install (33.062122ms)
✔ startup refuses a project the sync bot cannot read (6.775512ms)
✔ startup refuses a configured label the pm bot cannot see (7.808352ms)
✔ startup refuses an expired credential and a missing sync credential (15.64388ms)
✔ an unreachable tracker refuses with tracker-unavailable (6.503175ms)
✔ an edit in the UI is recorded once, with the fields that changed (79.122007ms)
✔ a move between open buckets is seen on the board, though updated does not change (37.816485ms)
✔ a person's comment is counted and a bot's is not (65.42748ms)
✔ the hourly reconcile catches a comment through comment_count (75.271868ms)
✔ a task closed in the UI leaves the open view with its done bucket (108.166875ms)
✔ a task that leaves the board is recorded as deleted, moved or out of reach (60.936271ms)
✔ a poll that read before a verb wrote does not overwrite the verb (32.571499ms)
✔ a tracker fault during a tick is reported and the next tick catches up (34.564496ms)
✔ a malformed answer refuses the tick with tracker-shape (11.06167ms)
✔ no token value reaches the database, the log or a refusal (42.85857ms)
✔ the first look at a task counts only comments inside the window (35.713216ms)
✔ task.create needs a recorded human request and a requirement id (37.987302ms)
✔ only labels named in the business file can be written (25.590207ms)
✔ task.schedule sets and clears a due date and relations (30.728864ms)
✔ assign and reassign move the role bots and record task.assigned (51.194862ms)
✔ task.update.assigned is for the assignee and records task.state (49.737162ms)
✔ a wrong expected digest records task.conflict and writes nothing (16.178182ms)
✔ a cross-role verb needs a resolved decision, used once (19.604384ms)
✔ task.close needs a verdict; after it every verb refuses with task-done (19.639506ms)
✔ a lost answer is settled by a re-read and never retried (22.484145ms)
✔ a create whose answer is lost is reported uncertain, and the poll finds the task (34.524476ms)
✔ a task the sync bot cannot read refuses and records nothing (13.739975ms)
✔ verbs and polls for one business run one at a time (83.390667ms)
✔ a due date with milliseconds is written to the second (60.693013ms)
✔ every write landed and the final read failed: the verb succeeds and records what it wrote (36.698399ms)
✔ some writes landed and the final read failed: write-uncertain, and nothing is recorded (13.750686ms)
✔ a create whose final read fails succeeds and records task.created (33.557305ms)
✔ an edit between the last write and the final read shows as external on the next poll (35.962535ms)
✔ task.created is recorded when a later label write fails (11.383503ms)
ℹ tests 51
ℹ suites 0
ℹ pass 51
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 683.275814
@@ -0,0 +1,23 @@
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (2176.618252ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (2178.390524ms)
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (1980.754608ms)
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (1284.842187ms)
✔ conversation view: a newer session with no readable history keeps the marker (854.620038ms)
✔ conversation view: seats without history say so and offer no reply (559.16708ms)
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (1658.06713ms)
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (52597.312061ms)
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (1714.825956ms)
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (21573.357273ms)
✔ loopback host and board origin fail closed (6.341086ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (76.074161ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (47.426114ms)
✔ unreachable board reports URL; CLI rejects unsupported options (411.36665ms)
ℹ tests 14
ℹ suites 0
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 52851.310746
@@ -0,0 +1,8 @@
packages/cli/README.md: OK
packages/cli/src/host.mjs: OK
packages/cli/src/notifier.mjs: OK
packages/cli/tests/host.test.mjs: OK
packages/cli/tests/notifier.test.mjs: OK
packages/cli/tests/trackers-boot.test.mjs: OK
packages/discord/tests/journal.test.mjs: OK
scripts/bus-service.sh: OK
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (29.760605ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (40.387701ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (19.630873ms)
✔ decide prints a declining choice as declining (18.559833ms)
✔ an unknown outcome is reported once and never resent (19.251059ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.774078ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.392506ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (19.895737ms)
✔ every human command refuses inside an agent run before it touches the bus (21.584274ms)
✔ usage errors exit 4; no business and no host is a usage error (17.797736ms)
✔ agents and tasks print through the broker (16.859557ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.04971ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (74.7938ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.571683ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (60.48769ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (57.400902ms)
✔ a business without tracker.baseUrl gets no trackers entry (60.437397ms)
✔ an unknown business and a broken system config refuse with exit 3 (85.017421ms)
✔ empty views say so (1.072336ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.521006ms)
✔ tasks print the tracker fields the snapshot carries (0.24846ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (977.003584ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (140.658962ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (79.178326ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (133.82799ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (125.348276ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (98.619915ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (129.898413ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.795733ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (137.043961ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (27.113002ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.695286ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (208.18818ms)
✔ bus start refuses with exit 3 without a notifier config (88.154146ms)
✔ bus start runs until bus stop; status reports it while it runs (602.606373ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.695514ms)
✔ zoned uses the IANA zone across DST (28.539915ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (28.090678ms)
✔ two blocking decisions get two DMs with different nonces (27.568839ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.336713ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.089529ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (25.438663ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (26.87194ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (21.403229ms)
✔ 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 (143.282607ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (65.831031ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.145753ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (13.046842ms)
✔ an inbox read failure is logged and the next poll retries (0.793326ms)
✔ no Discord id reaches the journal or the log (12.404002ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.007638ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.188473ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.627255ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.496929ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.561601ms)
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.770868ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.339127ms)
✔ 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.511845ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.423929ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.491457ms)
✔ digest content stays within Discord's 2000 characters (0.29813ms)
✔ runLoop never overlaps ticks and stops after the one in flight (112.067241ms)
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 (369.773535ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (58.915322ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2285.30706ms)
✔ busExit and refuseInsideAgent (0.408639ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.188646ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.756135ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.597795ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.624234ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.999595ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.349429ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.400713ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.732256ms)
✔ authorize: open channel, listed user (2.101826ms)
✔ authorize: wrong guild (0.209367ms)
✔ authorize: no guild (DM) (0.162187ms)
✔ authorize: unlisted channel (0.179388ms)
✔ authorize: unknown channel, no info (0.382868ms)
✔ authorize: thread of listed parent (0.197633ms)
✔ authorize: thread of unlisted parent (0.160359ms)
✔ authorize: text channel that is not a thread and not listed (0.155157ms)
✔ authorize: unlisted user (0.991643ms)
✔ authorize: no author (0.295573ms)
✔ authorize: bot author (listed id, bot flag) (0.140998ms)
✔ authorize: system author (0.55616ms)
✔ authorize: the bot itself (0.099859ms)
✔ authorize: webhook (0.113831ms)
✔ authorize: mention channel without mention (0.136751ms)
✔ authorize: mention channel with bot mention (0.145852ms)
✔ authorize: mention channel with @everyone only (0.104218ms)
✔ authorize: mention channel mentioning someone else (0.10662ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.150781ms)
✔ authorize: private thread under mention channel, mentioned (0.094834ms)
✔ authorize: private thread under mention channel, not mentioned (0.077781ms)
✔ authorize: thread in another guild per channel info (0.081945ms)
✔ authorize: not an object (0.059755ms)
✔ authorize: no id (0.06135ms)
✔ authorize: oversize content is accepted and flagged (0.066517ms)
✔ authorize: exactly the limit is not oversize (0.066867ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.472156ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.154326ms)
✔ binding: a complete binding validates and is frozen (2.522631ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.552805ms)
✔ binding: empty allowlists refuse (0.474243ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.795377ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.668973ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.432969ms)
✔ binding: file must be 0600, regular, not a symlink (1.577308ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.402369ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (122.273784ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (1.82173ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (397.984547ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (198.528071ms)
✔ cli: run refuses when STOP is present, before any network use (139.989087ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.79887ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.297895ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.070442ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.563682ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.483077ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.077325ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.172985ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.471663ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (31.650079ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.809468ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.128839ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.115303ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.156008ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.454802ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.007986ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.213715ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.508761ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.672546ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (1.949083ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.817555ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.550261ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (5.493833ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (7.417453ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.881282ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.910144ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.472195ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.849994ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.776002ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.159095ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.417185ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.240973ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.659696ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (3.087237ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.685236ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (71.638604ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (45.725937ms)
✔ engine: one prompt, one turn, text and usage come back (56.573313ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (369.23746ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (234.018661ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.249644ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (226.457903ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (127.47982ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.341342ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.421315ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.317539ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.505815ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.748202ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.530213ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (52.15548ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.592007ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.584807ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.499142ms)
✔ gateway: op 9 resumable resumes (0.368765ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.76534ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.741377ms)
✔ gateway: close() is final and unparseable frames are ignored (0.408938ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (73.103743ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (66.480583ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (130.774678ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.470491ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (97.943837ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (88.474131ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (95.746066ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (200.028001ms)
✔ git: push pushes the named branch only and reports up to date (71.359196ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (82.998207ms)
✔ git: the credential helper answers get over https from a private file and nothing else (194.931382ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (759.877143ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.051919ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (95.710236ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.103282ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (3.743815ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (70.748104ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (339.267476ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.478894ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (23.98535ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.986754ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (45.428384ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (140.01096ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (86.917507ms)
✔ notices: a kind is recorded per UTC day and found again (0.479905ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.712815ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.022034ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.770634ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (56.608994ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (57.629133ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (42.713779ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (84.057658ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (665.388638ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.224846ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.491371ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.855465ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.855279ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.965654ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.49081ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.944926ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.625227ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.165291ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (26.520967ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.387858ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.198956ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.556572ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.614136ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.8708ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1012.976724ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.479047ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.557741ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.258712ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.206733ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.564429ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.434266ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (5.960139ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.559793ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.934678ms)
✔ tools: listing and search caps hold (11.939271ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.984883ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.626118ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.136801ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.307826ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.524896ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.015756ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.701988ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.340098ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.825342ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1022.850229ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.215255ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.220765ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.577992ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.207962ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3201.814177
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:332:1
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.770868ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: day: {"at":"2026-10-08T12:00:00.000Z","kind":"digest","decision":null,"day":"2026-13-45","outcome":"confirmed","messageId":"2"}
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:361:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.933054ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (40.586516ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (17.071468ms)
✔ decide prints a declining choice as declining (20.782453ms)
✔ an unknown outcome is reported once and never resent (15.525174ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.507269ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (16.66138ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (15.815819ms)
✔ every human command refuses inside an agent run before it touches the bus (13.792254ms)
✔ usage errors exit 4; no business and no host is a usage error (22.532964ms)
✔ agents and tasks print through the broker (16.142383ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.867672ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (93.712278ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (55.603432ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (54.026832ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (59.84363ms)
✔ a business without tracker.baseUrl gets no trackers entry (51.074233ms)
✔ an unknown business and a broken system config refuse with exit 3 (78.386348ms)
✔ empty views say so (1.271252ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.3584ms)
✔ tasks print the tracker fields the snapshot carries (0.215963ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (955.861046ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (147.043559ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (72.581752ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (127.282474ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (120.073724ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (74.485241ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (128.852161ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (74.254731ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (154.961828ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.825489ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.095679ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (199.499294ms)
✔ bus start refuses with exit 3 without a notifier config (84.245811ms)
✔ bus start runs until bus stop; status reports it while it runs (601.832061ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.056634ms)
✔ zoned uses the IANA zone across DST (25.127665ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (36.414145ms)
✔ two blocking decisions get two DMs with different nonces (18.357286ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.315889ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (16.223452ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (23.260855ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (26.808591ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (21.001178ms)
✔ 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 (149.53931ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (52.44175ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (13.60806ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (11.889479ms)
✔ an inbox read failure is logged and the next poll retries (0.84053ms)
✔ no Discord id reaches the journal or the log (11.681828ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.054497ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.500186ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.68153ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.521073ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.65964ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.43279ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.41309ms)
✔ 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.554531ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.440515ms)
✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.878258ms)
✔ digest content stays within Discord's 2000 characters (0.290256ms)
✔ runLoop never overlaps ticks and stops after the one in flight (109.820304ms)
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 (357.256831ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (62.429113ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2263.493502ms)
✔ busExit and refuseInsideAgent (0.404764ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.21458ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.657051ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.627824ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.478332ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (21.332276ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (5.963611ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (5.08662ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.496166ms)
✔ authorize: open channel, listed user (2.030134ms)
✔ authorize: wrong guild (0.193645ms)
✔ authorize: no guild (DM) (0.175263ms)
✔ authorize: unlisted channel (0.190754ms)
✔ authorize: unknown channel, no info (0.180484ms)
✔ authorize: thread of listed parent (0.415865ms)
✔ authorize: thread of unlisted parent (0.258485ms)
✔ authorize: text channel that is not a thread and not listed (0.147594ms)
✔ authorize: unlisted user (0.394631ms)
✔ authorize: no author (0.296242ms)
✔ authorize: bot author (listed id, bot flag) (0.1591ms)
✔ authorize: system author (0.143776ms)
✔ authorize: the bot itself (0.091701ms)
✔ authorize: webhook (0.113823ms)
✔ authorize: mention channel without mention (0.166028ms)
✔ authorize: mention channel with bot mention (2.248183ms)
✔ authorize: mention channel with @everyone only (0.258813ms)
✔ authorize: mention channel mentioning someone else (0.1227ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.096363ms)
✔ authorize: private thread under mention channel, mentioned (0.087809ms)
✔ authorize: private thread under mention channel, not mentioned (0.070144ms)
✔ authorize: thread in another guild per channel info (0.081757ms)
✔ authorize: not an object (0.066607ms)
✔ authorize: no id (0.057509ms)
✔ authorize: oversize content is accepted and flagged (0.064267ms)
✔ authorize: exactly the limit is not oversize (0.057648ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.485833ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.137947ms)
✔ binding: a complete binding validates and is frozen (2.609785ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.333335ms)
✔ binding: empty allowlists refuse (0.387055ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.5243ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.943568ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.214392ms)
✔ binding: file must be 0600, regular, not a symlink (2.328198ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.399884ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (115.048247ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.047758ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (384.18446ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (186.331009ms)
✔ cli: run refuses when STOP is present, before any network use (130.382041ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.777309ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.201288ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (27.547373ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.250452ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.607876ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.117282ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.197262ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.377091ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.636836ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (2.461827ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.585965ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.42583ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (41.695013ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.728311ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.742455ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.046873ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (1.741964ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.967723ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.054239ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.757555ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.440423ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.246304ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.360643ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.12885ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.921058ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.720076ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (1.092018ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.005291ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.209847ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.579851ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (2.193647ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.732547ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.872288ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.46565ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (51.958902ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (50.280945ms)
✔ engine: one prompt, one turn, text and usage come back (43.633685ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (363.478196ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (239.226412ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.390613ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.487928ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (137.513919ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (214.478157ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.2532ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.322802ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.5683ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.877002ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.269469ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.061713ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.405247ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.554267ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.51209ms)
✔ gateway: op 9 resumable resumes (0.33714ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.785509ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.502693ms)
✔ gateway: close() is final and unparseable frames are ignored (0.385894ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (74.382799ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (58.763881ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (121.730986ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.350252ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (95.164082ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (82.677506ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (83.789469ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (200.720392ms)
✔ git: push pushes the named branch only and reports up to date (73.770788ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (86.440968ms)
✔ git: the credential helper answers get over https from a private file and nothing else (201.00243ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (765.686154ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.665994ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (82.834681ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.045811ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.737672ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (65.452895ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (305.142207ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.395347ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.560325ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.028335ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (47.715684ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (147.940445ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (93.267207ms)
✔ notices: a kind is recorded per UTC day and found again (0.485469ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.702771ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.033328ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.585238ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (49.94715ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (51.181476ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (38.667192ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (74.673209ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (633.782043ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.715976ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.224635ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.593037ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.652803ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.88761ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.471402ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.888135ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.469303ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (1.845624ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.381107ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.91462ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.297829ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (3.778139ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.473772ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (2.461485ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1013.148874ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.430035ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.570636ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.258537ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.209404ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.661587ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.225792ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.677205ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.675501ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.702477ms)
✔ tools: listing and search caps hold (10.94082ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.855241ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.139971ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.249232ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.112909ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.751631ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.873977ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.880282ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.292493ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.842155ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1028.430881ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.252925ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.248327ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.722402ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.616528ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3127.553943
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:410:1
✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.878258ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
CliError: notify journal is not writable (EACCES): /mnt/storage/scratch/tmp/mosaic-cli-5J6JN7/notify/demo/sent.jsonl
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:417:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: notify journal is not writable (EACCES): /mnt/storage/scratch/tmp/mosaic-cli-5J6JN7/notify/demo/sent.jsonl
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:185:44)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:417:25
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:417:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (30.80592ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (37.941349ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (24.665665ms)
✔ decide prints a declining choice as declining (16.704628ms)
✔ an unknown outcome is reported once and never resent (18.362108ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (22.967575ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (17.921798ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (19.349994ms)
✔ every human command refuses inside an agent run before it touches the bus (23.929134ms)
✔ usage errors exit 4; no business and no host is a usage error (19.440884ms)
✔ agents and tasks print through the broker (18.568282ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.487931ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (69.7928ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (71.846687ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (57.044383ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (58.644273ms)
✔ a business without tracker.baseUrl gets no trackers entry (60.90405ms)
✔ an unknown business and a broken system config refuse with exit 3 (84.738424ms)
✔ empty views say so (0.990899ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.31507ms)
✔ tasks print the tracker fields the snapshot carries (0.201898ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (980.69948ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (141.208608ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (74.75781ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.932544ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (119.885869ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (84.033505ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (125.225389ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (79.318402ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (127.759584ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (20.647585ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.650482ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (211.881612ms)
✔ bus start refuses with exit 3 without a notifier config (86.249024ms)
✔ bus start runs until bus stop; status reports it while it runs (596.571005ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.992768ms)
✔ zoned uses the IANA zone across DST (26.355309ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (28.970798ms)
✔ two blocking decisions get two DMs with different nonces (20.501669ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.333487ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.656415ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (22.497303ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (29.097279ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (22.478863ms)
✔ 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 (152.705588ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (63.602167ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.846273ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.902115ms)
✔ an inbox read failure is logged and the next poll retries (0.696838ms)
✔ no Discord id reaches the journal or the log (10.193279ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.286875ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.83902ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.342191ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.548612ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.599609ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.983624ms)
✖ the journal: a symlinked directory refuses and says it is a link (0.811472ms)
✔ 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.509637ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.39318ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.466583ms)
✔ digest content stays within Discord's 2000 characters (0.845689ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.857557ms)
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 (376.993838ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (60.080609ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2271.743692ms)
✔ busExit and refuseInsideAgent (3.612956ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.25813ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.294402ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.549751ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.47292ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (14.969516ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.513918ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.227274ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.701524ms)
✔ authorize: open channel, listed user (1.959092ms)
✔ authorize: wrong guild (0.216453ms)
✔ authorize: no guild (DM) (0.170078ms)
✔ authorize: unlisted channel (0.195683ms)
✔ authorize: unknown channel, no info (0.23938ms)
✔ authorize: thread of listed parent (0.185313ms)
✔ authorize: thread of unlisted parent (0.159428ms)
✔ authorize: text channel that is not a thread and not listed (0.150142ms)
✔ authorize: unlisted user (0.962821ms)
✔ authorize: no author (0.270791ms)
✔ authorize: bot author (listed id, bot flag) (0.148066ms)
✔ authorize: system author (0.140795ms)
✔ authorize: the bot itself (0.105239ms)
✔ authorize: webhook (0.093834ms)
✔ authorize: mention channel without mention (0.155979ms)
✔ authorize: mention channel with bot mention (0.156979ms)
✔ authorize: mention channel with @everyone only (0.209198ms)
✔ authorize: mention channel mentioning someone else (0.572704ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.093317ms)
✔ authorize: private thread under mention channel, mentioned (0.087315ms)
✔ authorize: private thread under mention channel, not mentioned (0.063573ms)
✔ authorize: thread in another guild per channel info (0.062634ms)
✔ authorize: not an object (0.059682ms)
✔ authorize: no id (0.059357ms)
✔ authorize: oversize content is accepted and flagged (0.789309ms)
✔ authorize: exactly the limit is not oversize (0.086833ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.472122ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.187836ms)
✔ binding: a complete binding validates and is frozen (2.594047ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.43093ms)
✔ binding: empty allowlists refuse (0.640449ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.346187ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.700946ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.223966ms)
✔ binding: file must be 0600, regular, not a symlink (1.536256ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.450629ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (110.20281ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (1.845798ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (437.195233ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (198.834255ms)
✔ cli: run refuses when STOP is present, before any network use (140.07413ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.065311ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.126689ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.238463ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.816258ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.53663ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.12218ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.512308ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.88486ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.236004ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.001692ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (2.373969ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.243393ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.635205ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (35.994945ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (9.116644ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.278894ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.234631ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.540188ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.880503ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.732181ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.767746ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (6.139385ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.41613ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.126854ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.543543ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.348612ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.798247ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.750549ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.15992ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.468416ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.285245ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (1.184191ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.58938ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.469604ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (57.493171ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.29494ms)
✔ engine: one prompt, one turn, text and usage come back (51.675881ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (363.088551ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.510724ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.701379ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (232.035177ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (135.507868ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.449236ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.742805ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.235627ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.502668ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (427.168525ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.983317ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.06491ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.570315ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.813386ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.550125ms)
✔ gateway: op 9 resumable resumes (0.390354ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.833395ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.468572ms)
✔ gateway: close() is final and unparseable frames are ignored (0.411776ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (75.207396ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (65.182611ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (146.161346ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.419711ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (93.371319ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (114.644759ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (92.718272ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (215.787199ms)
✔ git: push pushes the named branch only and reports up to date (67.738301ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (81.54714ms)
✔ git: the credential helper answers get over https from a private file and nothing else (194.467721ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (735.065155ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.732554ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (100.769657ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.129857ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (7.58865ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (63.822334ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (374.981472ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.597039ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (32.402789ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.201646ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (42.397274ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (137.466081ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (86.666807ms)
✔ notices: a kind is recorded per UTC day and found again (0.40965ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.665911ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.356813ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.838863ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (49.890843ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (60.66429ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (46.115231ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (96.310687ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (670.803332ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.278146ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.892925ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.539036ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (1.440505ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.845807ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.476087ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.307226ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.887012ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.236875ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.831211ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.545179ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.060634ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.631006ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.676231ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.481228ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1016.264905ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.41958ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.921007ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.26201ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.337149ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.32135ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.367336ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.324124ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.206261ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (5.556235ms)
✔ tools: listing and search caps hold (14.90763ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.871245ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.010288ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.369828ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.393609ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.344622ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.153513ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.476181ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (4.200898ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.395614ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1032.626989ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.188592ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.227019ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.619079ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.172735ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3138.692892
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:373:1
✖ the journal: a symlinked directory refuses and says it is a link (0.811472ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
CliError: notify journal directory must be mode 0700 and owned by this user: /mnt/storage/scratch/tmp/mosaic-cli-vNPN55/notify/demo
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:380:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: notify journal directory must be mode 0700 and owned by this user: /mnt/storage/scratch/tmp/mosaic-cli-vNPN55/notify/demo
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:171:11)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:380:23
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:380:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,296 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.457669ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (39.776212ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (27.600945ms)
✔ decide prints a declining choice as declining (15.940212ms)
✔ an unknown outcome is reported once and never resent (16.749822ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.791857ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (13.234687ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (19.37181ms)
✔ every human command refuses inside an agent run before it touches the bus (23.136445ms)
✔ usage errors exit 4; no business and no host is a usage error (20.99572ms)
✔ agents and tasks print through the broker (17.491638ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.941933ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (79.89992ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.021301ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (57.661152ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (58.772844ms)
✔ a business without tracker.baseUrl gets no trackers entry (62.196252ms)
✔ an unknown business and a broken system config refuse with exit 3 (99.376599ms)
✔ empty views say so (1.116849ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.287586ms)
✔ tasks print the tracker fields the snapshot carries (0.38398ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (970.94765ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (141.475956ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (70.074611ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.594938ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (122.05514ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (83.471111ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (130.578086ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (80.773985ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (129.291133ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.951038ms)
✔ bus stop refuses to signal a live pid that is not a bus host (201.98111ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (214.706423ms)
✔ bus start refuses with exit 3 without a notifier config (83.562569ms)
✔ bus start runs until bus stop; status reports it while it runs (591.731588ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.066894ms)
✔ zoned uses the IANA zone across DST (27.340622ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (36.416407ms)
✔ two blocking decisions get two DMs with different nonces (31.196857ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.317673ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (28.754351ms)
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (1.583579ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (34.761097ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.059918ms)
✖ 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 (153.389297ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (71.565471ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (17.956347ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (14.297155ms)
✔ an inbox read failure is logged and the next poll retries (0.821125ms)
✔ no Discord id reaches the journal or the log (15.565565ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.563285ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.30832ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.759443ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.711908ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.691527ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.787096ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.413294ms)
✔ 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.60859ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.473971ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.594826ms)
✔ digest content stays within Discord's 2000 characters (0.330817ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.534829ms)
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 (386.924727ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (59.064308ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2271.756605ms)
✔ busExit and refuseInsideAgent (0.430765ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.227106ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.540684ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.62685ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.479888ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.627827ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.471183ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (6.524196ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.657375ms)
✔ authorize: open channel, listed user (2.064009ms)
✔ authorize: wrong guild (0.22122ms)
✔ authorize: no guild (DM) (0.181135ms)
✔ authorize: unlisted channel (0.22082ms)
✔ authorize: unknown channel, no info (0.170662ms)
✔ authorize: thread of listed parent (0.151932ms)
✔ authorize: thread of unlisted parent (0.154559ms)
✔ authorize: text channel that is not a thread and not listed (0.132508ms)
✔ authorize: unlisted user (0.22058ms)
✔ authorize: no author (1.165386ms)
✔ authorize: bot author (listed id, bot flag) (0.334929ms)
✔ authorize: system author (0.214155ms)
✔ authorize: the bot itself (0.12664ms)
✔ authorize: webhook (0.175548ms)
✔ authorize: mention channel without mention (0.181537ms)
✔ authorize: mention channel with bot mention (0.151418ms)
✔ authorize: mention channel with @everyone only (0.096097ms)
✔ authorize: mention channel mentioning someone else (0.079812ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.067777ms)
✔ authorize: private thread under mention channel, mentioned (0.089663ms)
✔ authorize: private thread under mention channel, not mentioned (0.076655ms)
✔ authorize: thread in another guild per channel info (0.105833ms)
✔ authorize: not an object (0.073392ms)
✔ authorize: no id (0.075666ms)
✔ authorize: oversize content is accepted and flagged (0.072669ms)
✔ authorize: exactly the limit is not oversize (0.074601ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.48884ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.200971ms)
✔ binding: a complete binding validates and is frozen (2.476085ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.226904ms)
✔ binding: empty allowlists refuse (0.368308ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.348988ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.751437ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.550265ms)
✔ binding: file must be 0600, regular, not a symlink (2.38952ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.983162ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (110.66854ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.066087ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (432.827415ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (206.863956ms)
✔ cli: run refuses when STOP is present, before any network use (136.110665ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.686294ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.092118ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (18.053257ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.088716ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.630096ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.245316ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.333303ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.411725ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.777624ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.04499ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.084472ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.654419ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (41.170181ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.153366ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.181913ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.288311ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.203992ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.428206ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.39863ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (2.096518ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.067026ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (5.268096ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.007624ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.652781ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.435234ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.61294ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.799959ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.948758ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.388242ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.46722ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.170258ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.659488ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.437656ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.418608ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (64.442493ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (54.892935ms)
✔ engine: one prompt, one turn, text and usage come back (49.692748ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (361.393325ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (241.635748ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.348408ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.414968ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (126.967524ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.927643ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.718972ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.317189ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.489047ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.586787ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.691376ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.180303ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.533128ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (2.885873ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.491052ms)
✔ gateway: op 9 resumable resumes (0.536362ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.821569ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.681609ms)
✔ gateway: close() is final and unparseable frames are ignored (0.392197ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (94.317873ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (58.533787ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (139.984006ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.442403ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (110.2365ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (93.031839ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (93.281368ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (196.280919ms)
✔ git: push pushes the named branch only and reports up to date (72.931362ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (78.976717ms)
✔ git: the credential helper answers get over https from a private file and nothing else (192.498725ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (746.924677ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.877264ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (82.470299ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.467376ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.535194ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (61.667284ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (368.701422ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.422508ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.696297ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.900262ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (45.148368ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (144.394417ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (82.2528ms)
✔ notices: a kind is recorded per UTC day and found again (0.410635ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.335288ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (1.767914ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.372908ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (55.475108ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (57.162387ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (52.573354ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (98.92471ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (665.11408ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (1.919958ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.139676ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.583988ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.734173ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.818297ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.721563ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.351536ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.819202ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.235031ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.702181ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.829086ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.098975ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (3.763734ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.844764ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.518365ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1017.65669ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.399851ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.949877ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.228252ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.193367ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.779223ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.994224ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.81424ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (7.962176ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (8.218101ms)
✔ tools: listing and search caps hold (13.779685ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.849999ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.298225ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.483929ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.991859ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.629344ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.361629ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.889447ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (4.019815ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.134085ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1026.529497ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.186143ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.230577ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.75257ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.254833ms)
ℹ tests 244
ℹ suites 0
ℹ pass 242
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3146.217285
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:113:1
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (1.583579ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
6 !== 5
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:114:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 6,
expected: 5,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/notifier.test.mjs:189:1
✖ 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 (153.389297ms)
AssertionError [ERR_ASSERTION]: gave-up line at +7200 s
false !== true
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:195:12
at pollUntil (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:183:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:194:19)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (27.146304ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (38.009662ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (18.5141ms)
✔ decide prints a declining choice as declining (18.628883ms)
✔ an unknown outcome is reported once and never resent (15.650932ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.287044ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (14.844071ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.900019ms)
✔ every human command refuses inside an agent run before it touches the bus (15.275902ms)
✔ usage errors exit 4; no business and no host is a usage error (13.997118ms)
✔ agents and tasks print through the broker (14.975116ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.754515ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (91.691289ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (57.69124ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (54.331019ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (51.439347ms)
✔ a business without tracker.baseUrl gets no trackers entry (65.184913ms)
✔ an unknown business and a broken system config refuse with exit 3 (77.553723ms)
✔ empty views say so (1.135468ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.399915ms)
✔ tasks print the tracker fields the snapshot carries (0.233921ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (962.551965ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (138.83737ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (75.153813ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.923681ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (124.460836ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (71.170747ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (124.842029ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (71.963873ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (136.068405ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.434272ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.119082ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (196.768748ms)
✔ bus start refuses with exit 3 without a notifier config (85.259772ms)
✔ bus start runs until bus stop; status reports it while it runs (591.2875ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.213333ms)
✔ zoned uses the IANA zone across DST (25.593634ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (28.853407ms)
✔ two blocking decisions get two DMs with different nonces (26.512062ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.340155ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (21.202885ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (25.705688ms)
✖ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (28.349376ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (19.973765ms)
✔ 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 (149.387782ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (55.829593ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.426272ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.611342ms)
✔ an inbox read failure is logged and the next poll retries (0.719418ms)
✔ no Discord id reaches the journal or the log (9.566831ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.769208ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.676623ms)
✔ the journal: a whole file that is one torn line truncates to empty (2.491676ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.554745ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.597171ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.126252ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.357005ms)
✔ 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.532459ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.439354ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.508479ms)
✔ digest content stays within Discord's 2000 characters (0.292882ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.349787ms)
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 (355.844502ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (58.458297ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2249.525617ms)
✔ busExit and refuseInsideAgent (0.44317ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.175311ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.678547ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.627134ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.500035ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (18.059746ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.855178ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.079715ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (3.047131ms)
✔ authorize: open channel, listed user (1.881817ms)
✔ authorize: wrong guild (0.199221ms)
✔ authorize: no guild (DM) (0.16743ms)
✔ authorize: unlisted channel (0.175829ms)
✔ authorize: unknown channel, no info (0.197697ms)
✔ authorize: thread of listed parent (0.185077ms)
✔ authorize: thread of unlisted parent (0.129646ms)
✔ authorize: text channel that is not a thread and not listed (0.309046ms)
✔ authorize: unlisted user (0.937763ms)
✔ authorize: no author (0.257256ms)
✔ authorize: bot author (listed id, bot flag) (0.133568ms)
✔ authorize: system author (0.123916ms)
✔ authorize: the bot itself (0.091698ms)
✔ authorize: webhook (0.102782ms)
✔ authorize: mention channel without mention (0.156143ms)
✔ authorize: mention channel with bot mention (0.753095ms)
✔ authorize: mention channel with @everyone only (0.233616ms)
✔ authorize: mention channel mentioning someone else (0.084316ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.091267ms)
✔ authorize: private thread under mention channel, mentioned (0.082723ms)
✔ authorize: private thread under mention channel, not mentioned (0.064701ms)
✔ authorize: thread in another guild per channel info (0.073075ms)
✔ authorize: not an object (0.083615ms)
✔ authorize: no id (0.067531ms)
✔ authorize: oversize content is accepted and flagged (0.103362ms)
✔ authorize: exactly the limit is not oversize (0.063306ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.54641ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.155689ms)
✔ binding: a complete binding validates and is frozen (3.013746ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.455639ms)
✔ binding: empty allowlists refuse (0.404366ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.109516ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.918847ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.501386ms)
✔ binding: file must be 0600, regular, not a symlink (1.386796ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.44632ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (106.593035ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.086633ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (383.108209ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (183.287555ms)
✔ cli: run refuses when STOP is present, before any network use (130.941356ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.626377ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.149413ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (18.081637ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (19.049916ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.587682ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.14477ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.299863ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.373868ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.784768ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.620864ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.173775ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.301099ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.660229ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.305697ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.665299ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.540588ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.873305ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (8.22036ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.538817ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.74908ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.939788ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.400576ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.188017ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.494767ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (7.690648ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.504002ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.967218ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.092757ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.257843ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.451567ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.750337ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.669448ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.327601ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.398387ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (64.031478ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (47.950717ms)
✔ engine: one prompt, one turn, text and usage come back (62.691644ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (352.153145ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (226.802245ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.529544ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.402728ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (124.690358ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.142582ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.032089ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.341782ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.49986ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (431.522471ms)
✔ engine: a malformed JSONL line fails the turn, not the process (27.058553ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.995574ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.419962ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.71561ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.466603ms)
✔ gateway: op 9 resumable resumes (0.316897ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.961517ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.522763ms)
✔ gateway: close() is final and unparseable frames are ignored (0.362003ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (63.942108ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (59.4284ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (126.799841ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.476755ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (92.874679ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (84.349771ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (80.517048ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (184.282742ms)
✔ git: push pushes the named branch only and reports up to date (66.455619ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (84.865589ms)
✔ git: the credential helper answers get over https from a private file and nothing else (199.420563ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (739.218079ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.214298ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (80.777969ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.71809ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.816216ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (57.795153ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (333.939421ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.394607ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.228986ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.016161ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (37.755237ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (144.249473ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (86.936735ms)
✔ notices: a kind is recorded per UTC day and found again (0.463881ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.922807ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.318563ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.672544ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (44.198484ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (50.913696ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (45.419862ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (82.671127ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (628.964212ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.388558ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.651155ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.637493ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.754378ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.86022ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.755052ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.092522ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.536178ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.111131ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (26.24324ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.209537ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.678632ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (3.778833ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.746281ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (2.707642ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1011.316703ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.447999ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.354564ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.37848ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.207054ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.357033ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.783929ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.102159ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.706791ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.239873ms)
✔ tools: listing and search caps hold (10.962071ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.786456ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (4.629923ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.284867ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.299665ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.346846ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.866692ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.853825ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (2.484076ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.602765ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1021.991413ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.151466ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.215167ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.625186ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.234811ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3086.142048
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:141:1
✖ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (28.349376ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
5 !== 15
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:155:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 5,
expected: 15,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,290 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (31.007842ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (35.866359ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.391436ms)
✔ decide prints a declining choice as declining (19.387843ms)
✔ an unknown outcome is reported once and never resent (19.143644ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (21.741923ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (17.358926ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (18.612049ms)
✔ every human command refuses inside an agent run before it touches the bus (18.413219ms)
✔ usage errors exit 4; no business and no host is a usage error (18.748875ms)
✔ agents and tasks print through the broker (17.446897ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.341661ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (70.12014ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (56.660283ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (65.793117ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (74.584839ms)
✔ a business without tracker.baseUrl gets no trackers entry (75.007179ms)
✔ an unknown business and a broken system config refuse with exit 3 (77.310781ms)
✔ empty views say so (1.165592ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (3.504443ms)
✔ tasks print the tracker fields the snapshot carries (0.260713ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (983.254691ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (140.487326ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (67.116026ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (127.533849ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (122.697523ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (71.66171ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (119.158434ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (69.160923ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (140.213827ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.294332ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.685519ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (199.922757ms)
✔ bus start refuses with exit 3 without a notifier config (85.877495ms)
✔ bus start runs until bus stop; status reports it while it runs (598.438975ms)
✔ bus-service.sh renders the unit and installs it into a given directory (33.100005ms)
✔ zoned uses the IANA zone across DST (25.377136ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (29.556827ms)
✔ two blocking decisions get two DMs with different nonces (22.368874ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.267481ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (17.861226ms)
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (24.111021ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (24.280414ms)
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (20.672223ms)
✔ 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 (158.174917ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (63.611281ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.884116ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.683243ms)
✔ an inbox read failure is logged and the next poll retries (0.775468ms)
✔ no Discord id reaches the journal or the log (10.520459ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.37734ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.169474ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.665332ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.798672ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.852685ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.641297ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.415441ms)
✔ 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.582724ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.413852ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.570654ms)
✔ digest content stays within Discord's 2000 characters (0.350281ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.808332ms)
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 (369.692362ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (58.761125ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2284.511013ms)
✔ busExit and refuseInsideAgent (3.544211ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.079533ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.677794ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.583406ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.4371ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.715242ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.759233ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.419207ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.241644ms)
✔ authorize: open channel, listed user (1.660324ms)
✔ authorize: wrong guild (0.207454ms)
✔ authorize: no guild (DM) (0.169451ms)
✔ authorize: unlisted channel (0.177561ms)
✔ authorize: unknown channel, no info (0.204644ms)
✔ authorize: thread of listed parent (0.202797ms)
✔ authorize: thread of unlisted parent (0.221182ms)
✔ authorize: text channel that is not a thread and not listed (0.114174ms)
✔ authorize: unlisted user (0.162636ms)
✔ authorize: no author (0.23081ms)
✔ authorize: bot author (listed id, bot flag) (0.133959ms)
✔ authorize: system author (0.12471ms)
✔ authorize: the bot itself (0.086604ms)
✔ authorize: webhook (0.114468ms)
✔ authorize: mention channel without mention (1.089119ms)
✔ authorize: mention channel with bot mention (0.145785ms)
✔ authorize: mention channel with @everyone only (0.189883ms)
✔ authorize: mention channel mentioning someone else (0.069336ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.08391ms)
✔ authorize: private thread under mention channel, mentioned (0.084706ms)
✔ authorize: private thread under mention channel, not mentioned (0.09793ms)
✔ authorize: thread in another guild per channel info (0.074934ms)
✔ authorize: not an object (0.0622ms)
✔ authorize: no id (0.058571ms)
✔ authorize: oversize content is accepted and flagged (0.071718ms)
✔ authorize: exactly the limit is not oversize (0.064527ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.469365ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.165844ms)
✔ binding: a complete binding validates and is frozen (2.510966ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.229942ms)
✔ binding: empty allowlists refuse (0.384401ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.510869ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.434975ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.638298ms)
✔ binding: file must be 0600, regular, not a symlink (1.767753ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.330397ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (109.04945ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.442108ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (397.439876ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (186.926601ms)
✔ cli: run refuses when STOP is present, before any network use (137.427307ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.768241ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.217834ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (18.518944ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.017566ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.27069ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.107805ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.260684ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.164596ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.4887ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.597669ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.911539ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.046037ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.201889ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.086235ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.394232ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.377634ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.620731ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.616873ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (5.391233ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.018179ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (5.171072ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (1.978544ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (6.662906ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.957156ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.654373ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.323015ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.928322ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.352459ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.191875ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.48947ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.479037ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.707397ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.432097ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.417485ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (59.088799ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.192571ms)
✔ engine: one prompt, one turn, text and usage come back (50.383243ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (355.020551ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.921125ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (105.633755ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.578243ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (127.578494ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.595517ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.372594ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.318042ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.53139ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (427.860818ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.345959ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.570271ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.38393ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.620558ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.507665ms)
✔ gateway: op 9 resumable resumes (0.366657ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.917196ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.547144ms)
✔ gateway: close() is final and unparseable frames are ignored (0.434264ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (70.772752ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (79.984173ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (147.588211ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.489374ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (90.947522ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (78.97579ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (82.346823ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (187.690137ms)
✔ git: push pushes the named branch only and reports up to date (70.506242ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (88.728135ms)
✔ git: the credential helper answers get over https from a private file and nothing else (198.772126ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (733.543221ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.727733ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (92.866591ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.271926ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.955178ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (68.79092ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (318.106323ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (3.525371ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.428769ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.918306ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (43.410185ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (134.866998ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (91.239056ms)
✔ notices: a kind is recorded per UTC day and found again (0.475589ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.387924ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.333288ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.846356ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (66.546212ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (54.64825ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (44.944134ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (73.643758ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (639.914514ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.563478ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.876985ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.883849ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (1.046819ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.903085ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.133513ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.531952ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (3.973928ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.711905ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.708008ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.824706ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (10.641994ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.062501ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.628745ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.415296ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1012.325492ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.495638ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.169644ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.237913ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.194271ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.287926ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.039662ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.636906ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.344053ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.921436ms)
✔ tools: listing and search caps hold (10.83548ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.886942ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.785223ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.340195ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.43212ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.644596ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.085522ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.870085ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.645311ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.578727ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1026.227949ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.258363ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.227004ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.49931ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.252013ms)
ℹ tests 244
ℹ suites 0
ℹ pass 242
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3112.839501
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:113:1
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (24.111021ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
8 !== 6
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:132:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 8,
expected: 6,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/cli/tests/notifier.test.mjs:161:1
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (20.672223ms)
AssertionError [ERR_ASSERTION]: one gave-up line only
7 !== 6
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:172:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 7,
expected: 6,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,275 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (35.907671ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (33.828051ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (19.564022ms)
✔ decide prints a declining choice as declining (23.123139ms)
✔ an unknown outcome is reported once and never resent (16.844214ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.757332ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (17.102004ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (15.783173ms)
✔ every human command refuses inside an agent run before it touches the bus (18.352242ms)
✔ usage errors exit 4; no business and no host is a usage error (17.123042ms)
✔ agents and tasks print through the broker (19.02021ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.12139ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (65.167648ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (63.185177ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (67.754525ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (59.879169ms)
✔ a business without tracker.baseUrl gets no trackers entry (63.30319ms)
✔ an unknown business and a broken system config refuse with exit 3 (91.113368ms)
✔ empty views say so (1.042294ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.288402ms)
✔ tasks print the tracker fields the snapshot carries (0.21112ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (964.781264ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (147.235114ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (76.35978ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (125.618169ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (122.116694ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (72.92106ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (125.31243ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.404511ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (134.518737ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.183271ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.825005ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (200.699607ms)
✔ bus start refuses with exit 3 without a notifier config (85.010098ms)
✔ bus start runs until bus stop; status reports it while it runs (599.278098ms)
✔ bus-service.sh renders the unit and installs it into a given directory (33.967937ms)
✔ zoned uses the IANA zone across DST (25.819193ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (26.259947ms)
✔ two blocking decisions get two DMs with different nonces (21.336884ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.310211ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.798177ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (25.335423ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (42.562172ms)
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.796835ms)
✔ 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 (147.398441ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (74.246448ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.563056ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.075605ms)
✔ an inbox read failure is logged and the next poll retries (0.714626ms)
✔ no Discord id reaches the journal or the log (8.910446ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.238968ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.977015ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.331085ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.556547ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.93961ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.160316ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.412259ms)
✔ 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.504315ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.424702ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.489339ms)
✔ digest content stays within Discord's 2000 characters (0.308606ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.245016ms)
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 (377.305092ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (61.886594ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2282.888322ms)
✔ busExit and refuseInsideAgent (0.396029ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.182482ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.495384ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.591696ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.516961ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.191805ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.896753ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (5.758235ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.653996ms)
✔ authorize: open channel, listed user (1.937768ms)
✔ authorize: wrong guild (0.19303ms)
✔ authorize: no guild (DM) (0.164671ms)
✔ authorize: unlisted channel (0.215374ms)
✔ authorize: unknown channel, no info (0.178852ms)
✔ authorize: thread of listed parent (0.190196ms)
✔ authorize: thread of unlisted parent (0.249874ms)
✔ authorize: text channel that is not a thread and not listed (0.122206ms)
✔ authorize: unlisted user (0.179826ms)
✔ authorize: no author (0.25968ms)
✔ authorize: bot author (listed id, bot flag) (3.498479ms)
✔ authorize: system author (0.454962ms)
✔ authorize: the bot itself (0.10011ms)
✔ authorize: webhook (0.103339ms)
✔ authorize: mention channel without mention (0.130293ms)
✔ authorize: mention channel with bot mention (0.145646ms)
✔ authorize: mention channel with @everyone only (0.105035ms)
✔ authorize: mention channel mentioning someone else (0.099156ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.094127ms)
✔ authorize: private thread under mention channel, mentioned (0.089244ms)
✔ authorize: private thread under mention channel, not mentioned (0.07785ms)
✔ authorize: thread in another guild per channel info (0.078183ms)
✔ authorize: not an object (0.067403ms)
✔ authorize: no id (0.069164ms)
✔ authorize: oversize content is accepted and flagged (0.072886ms)
✔ authorize: exactly the limit is not oversize (0.129506ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (2.508958ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.185243ms)
✔ binding: a complete binding validates and is frozen (2.55613ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (2.119311ms)
✔ binding: empty allowlists refuse (0.42883ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.595041ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (2.080632ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.262084ms)
✔ binding: file must be 0600, regular, not a symlink (1.637671ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.313326ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (120.14796ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (5.464029ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (386.124028ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (205.191133ms)
✔ cli: run refuses when STOP is present, before any network use (130.871351ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.703233ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.089932ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (18.387326ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (15.796369ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.699771ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.132473ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.412887ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.515809ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.367621ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.085594ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.055758ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.46701ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (44.293865ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.753552ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.05122ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.716244ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.365769ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.144234ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (1.834329ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.908962ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.630321ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.381295ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.355993ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.782928ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.127064ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.435241ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.788725ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.508875ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.169816ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.479615ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.257794ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.635396ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.390889ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.391207ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (59.415341ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (61.451709ms)
✔ engine: one prompt, one turn, text and usage come back (49.217009ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (352.992402ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (227.904937ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (103.911725ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (226.980309ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (131.690081ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.287434ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.728019ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.306726ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.509402ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (425.816542ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.212649ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.235066ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.780269ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.789716ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.464944ms)
✔ gateway: op 9 resumable resumes (0.390011ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.78067ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.495854ms)
✔ gateway: close() is final and unparseable frames are ignored (0.41733ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (70.383607ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (67.176795ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (152.699022ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.465056ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (94.365656ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (87.974451ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (84.478469ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (211.105093ms)
✔ git: push pushes the named branch only and reports up to date (65.591525ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (82.639005ms)
✔ git: the credential helper answers get over https from a private file and nothing else (196.923492ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (732.40599ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.823601ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (90.98917ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.117745ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (3.804433ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (62.068597ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (330.460986ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.40533ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (26.944861ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.995838ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (43.039273ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (146.950752ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (84.591369ms)
✔ notices: a kind is recorded per UTC day and found again (0.440065ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.278016ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.047214ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.770043ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (60.225454ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (50.438925ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (48.919357ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (77.559074ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (644.227422ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.610182ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.945677ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (1.025202ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.732875ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.053592ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.302412ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.850869ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.867226ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.541066ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (27.741019ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (14.919383ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.898873ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.028128ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.679448ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.291423ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1014.093062ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.414768ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.972973ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.189231ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.199192ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.219535ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.023123ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.68059ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (6.089698ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.554985ms)
✔ tools: listing and search caps hold (13.116831ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.705526ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (7.182148ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.260686ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.220933ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.45877ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.804999ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.046206ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.53915ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.273334ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1025.200986ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.323866ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.22155ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.613232ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.19972ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3114.589808
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:161:1
✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.796835ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'refused'
- 'gave-up'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:169:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'refused',
expected: 'gave-up',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,275 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (28.621283ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.418507ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.995275ms)
✔ decide prints a declining choice as declining (22.836862ms)
✔ an unknown outcome is reported once and never resent (15.164053ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.187221ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (21.632191ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.957876ms)
✔ every human command refuses inside an agent run before it touches the bus (15.069813ms)
✔ usage errors exit 4; no business and no host is a usage error (21.033209ms)
✔ agents and tasks print through the broker (16.857571ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.228282ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (66.96735ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (59.574632ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (57.733999ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (65.292768ms)
✔ a business without tracker.baseUrl gets no trackers entry (53.459236ms)
✔ an unknown business and a broken system config refuse with exit 3 (90.547387ms)
✔ empty views say so (1.061084ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.431715ms)
✔ tasks print the tracker fields the snapshot carries (0.217874ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (961.827261ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (144.385665ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (69.599438ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (124.221887ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (119.2421ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (75.874011ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (120.182931ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (74.915202ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (131.184086ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.819526ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.474489ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.010171ms)
✔ bus start refuses with exit 3 without a notifier config (83.345606ms)
✔ bus start runs until bus stop; status reports it while it runs (592.614018ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.484325ms)
✔ zoned uses the IANA zone across DST (27.698692ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (31.164826ms)
✔ two blocking decisions get two DMs with different nonces (21.953427ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.374516ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.330818ms)
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (33.696536ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (24.452153ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.698773ms)
✔ 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 (147.101493ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (53.663954ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (18.172301ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.095754ms)
✔ an inbox read failure is logged and the next poll retries (0.75751ms)
✔ no Discord id reaches the journal or the log (11.053261ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (1.910362ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.04545ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.120175ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.461928ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.523453ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.001405ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.313132ms)
✔ 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.453771ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.363517ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.441764ms)
✔ digest content stays within Discord's 2000 characters (0.265474ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.780231ms)
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 (361.507281ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (61.19871ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2254.738866ms)
✔ busExit and refuseInsideAgent (0.418056ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (2.979819ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.41399ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.536152ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.942113ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (18.18381ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.478598ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.082299ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.086992ms)
✔ authorize: open channel, listed user (1.868361ms)
✔ authorize: wrong guild (0.339308ms)
✔ authorize: no guild (DM) (0.355702ms)
✔ authorize: unlisted channel (0.216537ms)
✔ authorize: unknown channel, no info (0.169905ms)
✔ authorize: thread of listed parent (0.18777ms)
✔ authorize: thread of unlisted parent (0.132027ms)
✔ authorize: text channel that is not a thread and not listed (0.141871ms)
✔ authorize: unlisted user (0.179529ms)
✔ authorize: no author (0.662374ms)
✔ authorize: bot author (listed id, bot flag) (0.139891ms)
✔ authorize: system author (0.13039ms)
✔ authorize: the bot itself (0.093409ms)
✔ authorize: webhook (0.159174ms)
✔ authorize: mention channel without mention (0.133873ms)
✔ authorize: mention channel with bot mention (0.158685ms)
✔ authorize: mention channel with @everyone only (0.122146ms)
✔ authorize: mention channel mentioning someone else (0.087096ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.086022ms)
✔ authorize: private thread under mention channel, mentioned (0.100617ms)
✔ authorize: private thread under mention channel, not mentioned (0.075227ms)
✔ authorize: thread in another guild per channel info (0.074536ms)
✔ authorize: not an object (0.066614ms)
✔ authorize: no id (0.086367ms)
✔ authorize: oversize content is accepted and flagged (0.075215ms)
✔ authorize: exactly the limit is not oversize (0.074069ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.44869ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (1.276891ms)
✔ binding: a complete binding validates and is frozen (2.578559ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.732603ms)
✔ binding: empty allowlists refuse (0.375719ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.323385ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.651178ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.79816ms)
✔ binding: file must be 0600, regular, not a symlink (2.016077ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.602531ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (115.297034ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.831267ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (367.802337ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (187.692232ms)
✔ cli: run refuses when STOP is present, before any network use (127.309151ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.537031ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.020843ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.547014ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.720471ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.564107ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.096813ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.272855ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.61944ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.390387ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.30189ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.200616ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (2.180326ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.461218ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.916724ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (9.127388ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.259657ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.305258ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.722387ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.125464ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.652024ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (5.727573ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (1.535223ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.050562ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.428525ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.172906ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (3.319807ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.79182ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.908327ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.213356ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.322505ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.381823ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (1.2548ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.445298ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.507578ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (60.985253ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.847787ms)
✔ engine: one prompt, one turn, text and usage come back (50.762557ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (351.806729ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (240.035379ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (103.509997ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (225.043251ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (128.43759ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (214.54819ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.924449ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.308751ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.531149ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (425.910117ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.935939ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.315462ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.452899ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.7736ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.611255ms)
✔ gateway: op 9 resumable resumes (0.341012ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.877711ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.516388ms)
✔ gateway: close() is final and unparseable frames are ignored (0.447957ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (68.486843ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (59.495346ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (134.453382ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.458105ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (89.916991ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (75.23857ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (85.488293ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (192.418581ms)
✔ git: push pushes the named branch only and reports up to date (66.165731ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (77.232416ms)
✔ git: the credential helper answers get over https from a private file and nothing else (196.747174ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (731.926544ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.755713ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (82.249044ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.127505ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.18822ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (57.270022ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (333.069339ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.454178ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.855622ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.979474ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (34.582006ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (129.468697ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (79.569227ms)
✔ notices: a kind is recorded per UTC day and found again (0.411317ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (8.930572ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.046757ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.780801ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.67267ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (52.373179ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (44.581135ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (69.44625ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (614.78035ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.500519ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (6.121771ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.691956ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.707289ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.874534ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.147746ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.489601ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.579195ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.59566ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.720314ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.519307ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.762805ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.01743ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.796999ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.226122ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1012.079624ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.444775ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.03868ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.289155ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.206254ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.316366ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.437806ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.630721ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.564635ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.425491ms)
✔ tools: listing and search caps hold (11.546249ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.435662ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (13.51128ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (0.972228ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.345303ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.886211ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.701352ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.690755ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.529186ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.258573ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1025.389937ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.242156ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.226014ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.526761ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.78514ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3082.015709
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:113:1
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (33.696536ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /\[blocking, DM refused, not retried\] a8df1921 git\.push\.protected/. Input:
'Mosaic digest (demo, 2026-10-08): 1 open decision(s).\n' +
'- [blocking, DM pending] a8df1921 git.push.protected: Push the release?\n' +
'Run mosaic inbox for the full list.'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:138:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'Mosaic digest (demo, 2026-10-08): 1 open decision(s).\n- [blocking, DM pending] a8df1921 git.push.protected: Push the release?\nRun mosaic inbox for the full list.',
expected: /\[blocking, DM refused, not retried\] a8df1921 git\.push\.protected/,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,300 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (56.885504ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.287014ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (17.265434ms)
✔ decide prints a declining choice as declining (14.985414ms)
✔ an unknown outcome is reported once and never resent (16.212657ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (14.862663ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (14.512272ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (22.653118ms)
✔ every human command refuses inside an agent run before it touches the bus (24.838053ms)
✔ usage errors exit 4; no business and no host is a usage error (18.465952ms)
✔ agents and tasks print through the broker (14.697332ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.42752ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (74.09765ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (57.52387ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (56.072894ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (52.204553ms)
✔ a business without tracker.baseUrl gets no trackers entry (63.35558ms)
✔ an unknown business and a broken system config refuse with exit 3 (79.893582ms)
✔ empty views say so (1.21304ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.380745ms)
✔ tasks print the tracker fields the snapshot carries (0.22152ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (942.790779ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (155.209653ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (75.197442ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (123.043863ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (121.441269ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (81.126446ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (127.683225ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (86.137999ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (131.315693ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.172735ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.028145ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (202.631136ms)
✔ bus start refuses with exit 3 without a notifier config (83.066616ms)
✔ bus start runs until bus stop; status reports it while it runs (602.933961ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.736454ms)
✔ zoned uses the IANA zone across DST (26.367569ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (61.280969ms)
✔ two blocking decisions get two DMs with different nonces (20.343789ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.326101ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (21.98434ms)
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (19.818272ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.372ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.619543ms)
✖ 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 (139.966927ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (49.035235ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (14.995437ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (13.299279ms)
✔ an inbox read failure is logged and the next poll retries (0.773214ms)
✔ no Discord id reaches the journal or the log (11.954278ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.146364ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.010685ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.809056ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.57811ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.710816ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.548265ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.388793ms)
✔ 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.528707ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.413836ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.505336ms)
✔ digest content stays within Discord's 2000 characters (0.31769ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.5451ms)
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 (350.945743ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (51.215414ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2235.504837ms)
✔ busExit and refuseInsideAgent (0.45267ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (2.985741ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.678746ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.640068ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.470898ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.894024ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.126932ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.108522ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.147988ms)
✔ authorize: open channel, listed user (1.828706ms)
✔ authorize: wrong guild (0.185022ms)
✔ authorize: no guild (DM) (0.177938ms)
✔ authorize: unlisted channel (0.211176ms)
✔ authorize: unknown channel, no info (0.184219ms)
✔ authorize: thread of listed parent (0.178391ms)
✔ authorize: thread of unlisted parent (0.177869ms)
✔ authorize: text channel that is not a thread and not listed (0.14313ms)
✔ authorize: unlisted user (0.182067ms)
✔ authorize: no author (0.294058ms)
✔ authorize: bot author (listed id, bot flag) (0.171007ms)
✔ authorize: system author (0.126633ms)
✔ authorize: the bot itself (0.0969ms)
✔ authorize: webhook (0.088551ms)
✔ authorize: mention channel without mention (0.137281ms)
✔ authorize: mention channel with bot mention (0.128178ms)
✔ authorize: mention channel with @everyone only (0.118136ms)
✔ authorize: mention channel mentioning someone else (0.095369ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.086469ms)
✔ authorize: private thread under mention channel, mentioned (0.084808ms)
✔ authorize: private thread under mention channel, not mentioned (0.068016ms)
✔ authorize: thread in another guild per channel info (0.072532ms)
✔ authorize: not an object (0.062472ms)
✔ authorize: no id (0.060983ms)
✔ authorize: oversize content is accepted and flagged (0.066501ms)
✔ authorize: exactly the limit is not oversize (0.062761ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.455448ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.138271ms)
✔ binding: a complete binding validates and is frozen (2.508739ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.24338ms)
✔ binding: empty allowlists refuse (0.387825ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.288192ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.587089ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.442067ms)
✔ binding: file must be 0600, regular, not a symlink (1.614418ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.442121ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (108.988243ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.913193ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (385.981748ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (198.132962ms)
✔ cli: run refuses when STOP is present, before any network use (125.979165ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.608937ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.173621ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (15.874528ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.952335ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.473261ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.592667ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.341225ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.70334ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.782675ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.044352ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.374301ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.042096ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (41.734104ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.251025ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.473614ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (9.356673ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.261338ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (9.648391ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.555416ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.771529ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.321657ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (4.769728ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (7.345102ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.643734ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (3.470697ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.347587ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.797221ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.765022ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.185081ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.362878ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.149406ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.642868ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.960776ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.43147ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (49.204733ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (47.370258ms)
✔ engine: one prompt, one turn, text and usage come back (45.890133ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (349.744044ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (230.082374ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (106.185494ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.972299ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (129.172254ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.782508ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (616.080922ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.279921ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.544898ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (429.71944ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.42561ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (43.133759ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.881829ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.556568ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.536634ms)
✔ gateway: op 9 resumable resumes (0.545814ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.870018ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.598496ms)
✔ gateway: close() is final and unparseable frames are ignored (0.619697ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (65.19501ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (58.813551ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (138.390132ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.312712ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (94.387699ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (94.891888ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (86.79196ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (203.185544ms)
✔ git: push pushes the named branch only and reports up to date (64.081443ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (86.495759ms)
✔ git: the credential helper answers get over https from a private file and nothing else (209.998925ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (733.972789ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.091593ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (82.294687ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.166123ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.122215ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (60.012183ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (331.721233ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.396141ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.525606ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.995717ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (34.459796ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (148.210668ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (82.366295ms)
✔ notices: a kind is recorded per UTC day and found again (0.472883ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.238448ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.216863ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.639008ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (52.236586ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (52.83315ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (47.624887ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (79.969359ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (679.403112ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.421208ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.031282ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.640044ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.703364ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.817813ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.170314ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.858315ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.405603ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.326149ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (27.642422ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.030959ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.350764ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.986666ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.607414ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.468382ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1016.286571ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.464161ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.485864ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.537642ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.199155ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.324383ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.380325ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.196114ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.302093ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.673284ms)
✔ tools: listing and search caps hold (12.447422ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.895993ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.84361ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (2.269688ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.375267ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.547906ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.774322ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.854376ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (5.010109ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.513761ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1030.900527ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.378903ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.23307ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (3.234016ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.811915ms)
ℹ tests 244
ℹ suites 0
ℹ pass 242
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3114.074441
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:113:1
✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (19.818272ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
'refused',
'refused',
'refused',
'refused',
'refused',
- 'gave-up'
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:127:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ 'refused', 'refused', 'refused', 'refused', 'refused' ],
expected: [ 'refused', 'refused', 'refused', 'refused', 'refused', 'gave-up' ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/cli/tests/notifier.test.mjs:189:1
✖ 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 (139.966927ms)
AssertionError [ERR_ASSERTION]: gave-up line at +7200 s
false !== true
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:195:12
at pollUntil (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:183:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:194:19)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (29.095741ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.923888ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (15.985505ms)
✔ decide prints a declining choice as declining (20.314714ms)
✔ an unknown outcome is reported once and never resent (13.899644ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.224943ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (17.276258ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (16.604299ms)
✔ every human command refuses inside an agent run before it touches the bus (19.184063ms)
✔ usage errors exit 4; no business and no host is a usage error (19.317868ms)
✔ agents and tasks print through the broker (17.139908ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.565535ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (72.467314ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (49.84195ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (52.10819ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (62.053938ms)
✔ a business without tracker.baseUrl gets no trackers entry (62.308204ms)
✔ an unknown business and a broken system config refuse with exit 3 (87.000304ms)
✔ empty views say so (1.115034ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.454864ms)
✔ tasks print the tracker fields the snapshot carries (0.226238ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (950.497988ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (141.486172ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (74.041411ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (127.53729ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (126.267882ms)
✖ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.29616ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (119.74423ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (69.877328ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (131.504147ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.031934ms)
✔ bus stop refuses to signal a live pid that is not a bus host (201.922926ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (201.441639ms)
✔ bus start refuses with exit 3 without a notifier config (87.594342ms)
✔ bus start runs until bus stop; status reports it while it runs (594.89957ms)
✔ bus-service.sh renders the unit and installs it into a given directory (33.862868ms)
✔ zoned uses the IANA zone across DST (27.366217ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (30.324315ms)
✔ two blocking decisions get two DMs with different nonces (20.355728ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.27787ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (24.206386ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (26.174087ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (29.841159ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.824088ms)
✔ 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 (152.302442ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (51.172318ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (14.327499ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.355138ms)
✔ an inbox read failure is logged and the next poll retries (0.718549ms)
✔ no Discord id reaches the journal or the log (9.433057ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.174015ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.297969ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.184247ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.484994ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.640183ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.202926ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.320092ms)
✔ 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.460622ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.390474ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.437026ms)
✔ digest content stays within Discord's 2000 characters (0.268666ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.030194ms)
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 (369.137409ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (61.50929ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2249.586124ms)
✔ busExit and refuseInsideAgent (0.41445ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.137569ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (2.194828ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.586076ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.51967ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (15.685404ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (8.000277ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.734034ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.779899ms)
✔ authorize: open channel, listed user (1.969847ms)
✔ authorize: wrong guild (0.198678ms)
✔ authorize: no guild (DM) (0.197448ms)
✔ authorize: unlisted channel (0.182072ms)
✔ authorize: unknown channel, no info (0.182055ms)
✔ authorize: thread of listed parent (0.210424ms)
✔ authorize: thread of unlisted parent (0.156645ms)
✔ authorize: text channel that is not a thread and not listed (0.137805ms)
✔ authorize: unlisted user (0.18579ms)
✔ authorize: no author (0.296704ms)
✔ authorize: bot author (listed id, bot flag) (0.145194ms)
✔ authorize: system author (0.312494ms)
✔ authorize: the bot itself (0.094985ms)
✔ authorize: webhook (0.752715ms)
✔ authorize: mention channel without mention (0.134448ms)
✔ authorize: mention channel with bot mention (0.116537ms)
✔ authorize: mention channel with @everyone only (0.083491ms)
✔ authorize: mention channel mentioning someone else (0.077775ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.082699ms)
✔ authorize: private thread under mention channel, mentioned (0.088658ms)
✔ authorize: private thread under mention channel, not mentioned (0.073754ms)
✔ authorize: thread in another guild per channel info (0.088674ms)
✔ authorize: not an object (0.054784ms)
✔ authorize: no id (0.063928ms)
✔ authorize: oversize content is accepted and flagged (0.074415ms)
✔ authorize: exactly the limit is not oversize (0.073527ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (2.03148ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.140374ms)
✔ binding: a complete binding validates and is frozen (2.424948ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.243753ms)
✔ binding: empty allowlists refuse (0.377074ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.271095ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.481653ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.552278ms)
✔ binding: file must be 0600, regular, not a symlink (2.326417ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.594778ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (108.406928ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (1.781137ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (402.476611ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (187.532858ms)
✔ cli: run refuses when STOP is present, before any network use (125.515579ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.741472ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.461955ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (16.566057ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.85597ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (2.728884ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.456175ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (2.03462ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.702603ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.958556ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.860861ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.012946ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.352594ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.615618ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.052034ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (9.503955ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.821725ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.474703ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.069742ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.144499ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.741144ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (7.842408ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (1.432428ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (3.965582ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.973037ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.621111ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.447485ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.819355ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.235456ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.253733ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.55866ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.282132ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.637805ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.643009ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.422296ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (52.264335ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (52.951878ms)
✔ engine: one prompt, one turn, text and usage come back (57.502895ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (382.727095ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.256983ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (114.165961ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (227.288806ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.812203ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.627369ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.402369ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.226298ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.508602ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.083474ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.897817ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.112548ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.412089ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.631675ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.449641ms)
✔ gateway: op 9 resumable resumes (0.348812ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.929052ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.611223ms)
✔ gateway: close() is final and unparseable frames are ignored (0.45368ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (67.794757ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (62.659566ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (128.676422ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.366912ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (89.399851ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (83.745301ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (77.88815ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (199.054856ms)
✔ git: push pushes the named branch only and reports up to date (65.829609ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (76.923938ms)
✔ git: the credential helper answers get over https from a private file and nothing else (194.349209ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (733.403237ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.521431ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (83.973747ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (2.295484ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (3.099574ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (73.940635ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (317.910765ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.409595ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (24.29891ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.056676ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (35.587816ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (135.680834ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (82.764193ms)
✔ notices: a kind is recorded per UTC day and found again (0.408213ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.075093ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.182406ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.7002ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (51.868684ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (58.914471ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (42.049844ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (88.275118ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (626.350216ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.622214ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.062453ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (1.240388ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.715139ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.852064ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.335935ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.818495ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.779181ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.000309ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.904269ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.925509ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.554198ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.364328ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.632652ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.753915ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1014.403495ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.42263ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.964611ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.225257ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.217242ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.34837ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (5.074763ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.775756ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.131013ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.821373ms)
✔ tools: listing and search caps hold (12.725447ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.936192ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.826119ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.864891ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.6133ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.489664ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.861216ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.853406ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (2.892454ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.799945ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1029.586208ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (6.060918ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.356494ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (3.239184ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.209551ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3096.592038
✖ failing tests:
test at packages/cli/tests/host.test.mjs:243:1
✖ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.29616ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 0
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:268:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 1,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,284 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (26.1368ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (46.745011ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (16.100877ms)
✔ decide prints a declining choice as declining (16.379141ms)
✔ an unknown outcome is reported once and never resent (17.447369ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (14.858296ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (14.287926ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (15.549323ms)
✔ every human command refuses inside an agent run before it touches the bus (16.259379ms)
✔ usage errors exit 4; no business and no host is a usage error (21.570527ms)
✔ agents and tasks print through the broker (16.178902ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.259346ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (74.942581ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (57.961992ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (54.682985ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (60.085701ms)
✔ a business without tracker.baseUrl gets no trackers entry (58.386835ms)
✔ an unknown business and a broken system config refuse with exit 3 (81.737818ms)
✔ empty views say so (0.967447ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.493325ms)
✔ tasks print the tracker fields the snapshot carries (0.215181ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (957.7209ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (146.582909ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (76.136108ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.968102ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (118.781911ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (69.989998ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (122.555871ms)
✖ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (70.183687ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (133.670931ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.088198ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.173423ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (205.297138ms)
✔ bus start refuses with exit 3 without a notifier config (84.370611ms)
✔ bus start runs until bus stop; status reports it while it runs (592.037185ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.73236ms)
✔ zoned uses the IANA zone across DST (27.008417ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (23.208353ms)
✔ two blocking decisions get two DMs with different nonces (19.924515ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.451612ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.04242ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (21.225929ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (24.649025ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (16.345159ms)
✔ 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 (140.119999ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (62.387294ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (14.681064ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (14.434875ms)
✔ an inbox read failure is logged and the next poll retries (0.837843ms)
✔ no Discord id reaches the journal or the log (12.389468ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.696068ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (4.681866ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.93143ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.809339ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.819608ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.484426ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.44415ms)
✔ 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.566352ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.441192ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.525ms)
✔ digest content stays within Discord's 2000 characters (0.539639ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.651376ms)
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 (360.952342ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (53.7094ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2240.251825ms)
✔ busExit and refuseInsideAgent (0.416736ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.308892ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.522903ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.645219ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.483178ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.376906ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.457576ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.343405ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.979697ms)
✔ authorize: open channel, listed user (1.743225ms)
✔ authorize: wrong guild (0.189864ms)
✔ authorize: no guild (DM) (0.268705ms)
✔ authorize: unlisted channel (0.171038ms)
✔ authorize: unknown channel, no info (0.150431ms)
✔ authorize: thread of listed parent (0.183056ms)
✔ authorize: thread of unlisted parent (0.13136ms)
✔ authorize: text channel that is not a thread and not listed (0.118599ms)
✔ authorize: unlisted user (1.06446ms)
✔ authorize: no author (0.237253ms)
✔ authorize: bot author (listed id, bot flag) (0.593525ms)
✔ authorize: system author (0.125774ms)
✔ authorize: the bot itself (0.116418ms)
✔ authorize: webhook (0.077649ms)
✔ authorize: mention channel without mention (0.12093ms)
✔ authorize: mention channel with bot mention (0.117308ms)
✔ authorize: mention channel with @everyone only (0.089981ms)
✔ authorize: mention channel mentioning someone else (0.097472ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.114099ms)
✔ authorize: private thread under mention channel, mentioned (0.934377ms)
✔ authorize: private thread under mention channel, not mentioned (0.10492ms)
✔ authorize: thread in another guild per channel info (0.08405ms)
✔ authorize: not an object (0.07059ms)
✔ authorize: no id (0.067651ms)
✔ authorize: oversize content is accepted and flagged (0.075171ms)
✔ authorize: exactly the limit is not oversize (0.064778ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.469229ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.138129ms)
✔ binding: a complete binding validates and is frozen (2.667906ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (2.00733ms)
✔ binding: empty allowlists refuse (0.429084ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.30046ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.540602ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.471465ms)
✔ binding: file must be 0600, regular, not a symlink (1.866653ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.336394ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (103.007293ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.545742ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (395.75902ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (190.884765ms)
✔ cli: run refuses when STOP is present, before any network use (134.217422ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.734418ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.181598ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (15.455274ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.43262ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.673261ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.407837ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.500103ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.579746ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.438276ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.533333ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (0.990361ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.394028ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (41.831246ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.960726ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (9.141976ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.363426ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.774124ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.97473ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.172664ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.817185ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (7.004484ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.134845ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.717394ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.680791ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.720045ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.528464ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.787622ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.868034ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.237296ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (2.345611ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.179714ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.653019ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.542918ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.394399ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (51.982367ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (54.041696ms)
✔ engine: one prompt, one turn, text and usage come back (52.543706ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (365.980906ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (226.717036ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (114.841503ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.378286ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (126.395957ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.982971ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.502266ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.28494ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.455757ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (425.889445ms)
✔ engine: a malformed JSONL line fails the turn, not the process (26.144857ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (47.886129ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.475985ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.50105ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.447689ms)
✔ gateway: op 9 resumable resumes (0.359406ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.904707ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.512303ms)
✔ gateway: close() is final and unparseable frames are ignored (0.40511ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (69.338568ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (59.822598ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (139.054817ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.443794ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (97.524835ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (87.910796ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (82.886667ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (189.922796ms)
✔ git: push pushes the named branch only and reports up to date (65.437666ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (84.476317ms)
✔ git: the credential helper answers get over https from a private file and nothing else (189.720326ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (734.069404ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.48591ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (81.743585ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.043907ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.96132ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (57.800739ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (340.613444ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.369071ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.297509ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.978547ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (36.117537ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (137.026522ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (81.489355ms)
✔ notices: a kind is recorded per UTC day and found again (0.459774ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.033554ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.574369ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.694562ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (47.965925ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (46.456693ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (44.562243ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (76.745919ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (632.318303ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.507474ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.51626ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.641018ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.735424ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.814947ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.569093ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.671653ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.797279ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.193978ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (30.796964ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.823469ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (6.984046ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.841136ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.603736ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.303258ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1016.621822ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.452632ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.514694ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.337529ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.210321ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.898733ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.162243ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.90781ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.823158ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.699656ms)
✔ tools: listing and search caps hold (12.142446ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.913146ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.991941ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.311991ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.285283ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.303826ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.770847ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.446206ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.261044ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.095035ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1027.890644ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.623231ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.271071ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.880563ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.241811ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 21892.726774
✖ failing tests:
test at packages/cli/tests/host.test.mjs:282:1
✖ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (70.183687ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: write EPIPE
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:298:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: write EPIPE
at epipe (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:113:24)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:288:35
at ChildProcess.send (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:94:23)
at startHost (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/host.mjs:144:36)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async waitForActual (node:assert:615:5)
at async strict.rejects (node:assert:738:25)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:298:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7),
operator: 'rejects',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (36.137224ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (32.80617ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.446641ms)
✔ decide prints a declining choice as declining (22.61046ms)
✔ an unknown outcome is reported once and never resent (18.116597ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (14.993634ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (16.118092ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.030829ms)
✔ every human command refuses inside an agent run before it touches the bus (16.339592ms)
✔ usage errors exit 4; no business and no host is a usage error (16.636658ms)
✔ agents and tasks print through the broker (23.719494ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.803717ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (67.682761ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (63.452436ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (60.125592ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (55.93854ms)
✔ a business without tracker.baseUrl gets no trackers entry (63.122419ms)
✔ an unknown business and a broken system config refuse with exit 3 (82.102685ms)
✔ empty views say so (1.078813ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.428027ms)
✔ tasks print the tracker fields the snapshot carries (0.206948ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (952.526967ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (154.740474ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (78.082734ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.007999ms)
✖ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (134.711417ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (86.036456ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (124.9122ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.737395ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (138.083174ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (21.677089ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.200301ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (199.31374ms)
✔ bus start refuses with exit 3 without a notifier config (85.678937ms)
✔ bus start runs until bus stop; status reports it while it runs (598.861125ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.648972ms)
✔ zoned uses the IANA zone across DST (25.186616ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (31.115707ms)
✔ two blocking decisions get two DMs with different nonces (23.120288ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.295168ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.592593ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (34.223143ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.865874ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (15.390973ms)
✔ 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 (149.241935ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (61.050772ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (17.366519ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.444671ms)
✔ an inbox read failure is logged and the next poll retries (0.794404ms)
✔ no Discord id reaches the journal or the log (9.407856ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.757199ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.164529ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.329633ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.606127ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.762642ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.845129ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.623139ms)
✔ 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.570491ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.434198ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.62767ms)
✔ digest content stays within Discord's 2000 characters (0.337211ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.23101ms)
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 (357.441666ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (66.744628ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2257.702429ms)
✔ busExit and refuseInsideAgent (0.401325ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.122055ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.395797ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.557231ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.470742ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.128501ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (5.948692ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.001664ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.839765ms)
✔ authorize: open channel, listed user (1.905814ms)
✔ authorize: wrong guild (0.194095ms)
✔ authorize: no guild (DM) (0.331274ms)
✔ authorize: unlisted channel (0.186257ms)
✔ authorize: unknown channel, no info (0.255594ms)
✔ authorize: thread of listed parent (0.175622ms)
✔ authorize: thread of unlisted parent (0.24057ms)
✔ authorize: text channel that is not a thread and not listed (0.146466ms)
✔ authorize: unlisted user (1.074803ms)
✔ authorize: no author (0.480332ms)
✔ authorize: bot author (listed id, bot flag) (0.227986ms)
✔ authorize: system author (0.118093ms)
✔ authorize: the bot itself (0.088781ms)
✔ authorize: webhook (0.095951ms)
✔ authorize: mention channel without mention (0.125265ms)
✔ authorize: mention channel with bot mention (0.1328ms)
✔ authorize: mention channel with @everyone only (0.100235ms)
✔ authorize: mention channel mentioning someone else (0.150046ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.104864ms)
✔ authorize: private thread under mention channel, mentioned (0.100161ms)
✔ authorize: private thread under mention channel, not mentioned (0.088155ms)
✔ authorize: thread in another guild per channel info (0.079789ms)
✔ authorize: not an object (0.072272ms)
✔ authorize: no id (0.12767ms)
✔ authorize: oversize content is accepted and flagged (0.079619ms)
✔ authorize: exactly the limit is not oversize (0.074017ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.465493ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.146439ms)
✔ binding: a complete binding validates and is frozen (3.168897ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.552052ms)
✔ binding: empty allowlists refuse (0.522861ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.345365ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.735368ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.189422ms)
✔ binding: file must be 0600, regular, not a symlink (1.825939ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.850965ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (109.72649ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.939662ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (382.730718ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (209.193251ms)
✔ cli: run refuses when STOP is present, before any network use (135.875836ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.16764ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (2.42409ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.243776ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.980429ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.535753ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.148879ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.529248ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.88324ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.555626ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.226429ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.11603ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.054395ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.21392ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.449019ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.234295ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (3.867423ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.231827ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.131194ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.656934ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.787371ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.163094ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (6.415917ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.456546ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.055967ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (3.92862ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.694643ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.781465ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.875644ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.209294ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (2.804381ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (4.91737ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.813091ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.566532ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.636779ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (61.082941ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (53.986925ms)
✔ engine: one prompt, one turn, text and usage come back (49.689667ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (357.99446ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (231.366643ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.917301ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (236.778841ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (128.503987ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.70331ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.106109ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.275196ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.53512ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (429.244086ms)
✔ engine: a malformed JSONL line fails the turn, not the process (30.163175ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.049539ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.741781ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.527813ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.754199ms)
✔ gateway: op 9 resumable resumes (0.359216ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.906629ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.503822ms)
✔ gateway: close() is final and unparseable frames are ignored (0.432442ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (69.647007ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (65.493273ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (120.122151ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.424613ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (94.670584ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (78.869019ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (86.12904ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (218.325974ms)
✔ git: push pushes the named branch only and reports up to date (81.564606ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (95.476163ms)
✔ git: the credential helper answers get over https from a private file and nothing else (210.664251ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (753.956112ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.227289ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (88.618108ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.186297ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (3.277209ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (61.174792ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (333.275407ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.377945ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (24.615643ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.033357ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (44.387525ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (155.641805ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (104.850567ms)
✔ notices: a kind is recorded per UTC day and found again (0.497782ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (3.731311ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (1.79075ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.825007ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (51.237049ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (50.659425ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (47.28091ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (74.125674ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (704.036932ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.316203ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (6.632113ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.610573ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (1.213551ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.407844ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.684309ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.034095ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.741239ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.165727ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (26.55187ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.232172ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.824441ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (6.488054ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.659137ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.330052ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1013.245534ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.604235ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.280297ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.252352ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.363548ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.277544ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.343008ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.452761ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.450104ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.691864ms)
✔ tools: listing and search caps hold (9.44943ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.738477ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.475546ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.280617ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.474486ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.151292ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.23079ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.745754ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (2.97183ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.224102ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1025.702489ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.230836ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.261957ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.620959ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.493582ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3139.768113
✖ failing tests:
test at packages/cli/tests/host.test.mjs:219:1
✖ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (134.711417ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 0
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:240:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 1,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,284 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (26.600514ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.705587ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (16.769242ms)
✔ decide prints a declining choice as declining (15.135272ms)
✔ an unknown outcome is reported once and never resent (15.031737ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (16.435094ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.768125ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (17.572267ms)
✔ every human command refuses inside an agent run before it touches the bus (15.354229ms)
✔ usage errors exit 4; no business and no host is a usage error (21.055411ms)
✔ agents and tasks print through the broker (23.168222ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.376503ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (64.768109ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (52.772937ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (53.69811ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (55.925369ms)
✔ a business without tracker.baseUrl gets no trackers entry (56.035949ms)
✔ an unknown business and a broken system config refuse with exit 3 (92.967208ms)
✔ empty views say so (0.953993ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.154696ms)
✔ tasks print the tracker fields the snapshot carries (0.221803ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (962.276869ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (143.48731ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (70.299392ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (126.799875ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (123.002067ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (76.269733ms)
✖ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (141.222031ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (73.507758ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (131.554124ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (25.680169ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.905447ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (194.944408ms)
✔ bus start refuses with exit 3 without a notifier config (85.043443ms)
✔ bus start runs until bus stop; status reports it while it runs (594.029709ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.50059ms)
✔ zoned uses the IANA zone across DST (29.044084ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (27.900183ms)
✔ two blocking decisions get two DMs with different nonces (20.115312ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.286355ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.363176ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (22.232889ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (21.679329ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (20.100012ms)
✔ 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 (144.371044ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (58.039443ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.025702ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.831656ms)
✔ an inbox read failure is logged and the next poll retries (0.64579ms)
✔ no Discord id reaches the journal or the log (11.503299ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.144653ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.265131ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.494834ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.682916ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.681227ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.011013ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.337943ms)
✔ 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.522107ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.371251ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.462659ms)
✔ digest content stays within Discord's 2000 characters (0.279541ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.507806ms)
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 (359.906235ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (59.911226ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2254.18594ms)
✔ busExit and refuseInsideAgent (0.39255ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (2.97925ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.362717ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.55241ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.464538ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (14.744728ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.923578ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (5.872745ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.252777ms)
✔ authorize: open channel, listed user (1.698945ms)
✔ authorize: wrong guild (0.1847ms)
✔ authorize: no guild (DM) (0.15856ms)
✔ authorize: unlisted channel (0.215869ms)
✔ authorize: unknown channel, no info (0.189214ms)
✔ authorize: thread of listed parent (0.143098ms)
✔ authorize: thread of unlisted parent (0.123843ms)
✔ authorize: text channel that is not a thread and not listed (0.095182ms)
✔ authorize: unlisted user (0.162611ms)
✔ authorize: no author (0.271775ms)
✔ authorize: bot author (listed id, bot flag) (0.147219ms)
✔ authorize: system author (0.116192ms)
✔ authorize: the bot itself (0.066576ms)
✔ authorize: webhook (0.077195ms)
✔ authorize: mention channel without mention (0.142467ms)
✔ authorize: mention channel with bot mention (0.132994ms)
✔ authorize: mention channel with @everyone only (0.117532ms)
✔ authorize: mention channel mentioning someone else (0.076602ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.106192ms)
✔ authorize: private thread under mention channel, mentioned (0.062647ms)
✔ authorize: private thread under mention channel, not mentioned (0.040465ms)
✔ authorize: thread in another guild per channel info (0.042334ms)
✔ authorize: not an object (0.058914ms)
✔ authorize: no id (0.062515ms)
✔ authorize: oversize content is accepted and flagged (0.069489ms)
✔ authorize: exactly the limit is not oversize (0.069454ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.561842ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.149422ms)
✔ binding: a complete binding validates and is frozen (2.4084ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.368569ms)
✔ binding: empty allowlists refuse (0.374772ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.468965ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.6009ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.92465ms)
✔ binding: file must be 0600, regular, not a symlink (1.615372ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.411698ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (106.654393ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (6.38343ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (403.239559ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (180.539463ms)
✔ cli: run refuses when STOP is present, before any network use (118.674373ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.816907ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.367893ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.692579ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.058789ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (2.374568ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.107055ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.261762ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.934659ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.277506ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.171679ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (2.378805ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.154108ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.159283ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.356294ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.220538ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.597752ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.570117ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.052237ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.873245ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (2.223108ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.623839ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.797385ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (6.379171ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.834078ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.345558ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.5404ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.759062ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.97261ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.173579ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.33179ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.233988ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.672229ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.400304ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.415409ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (46.132355ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (56.214513ms)
✔ engine: one prompt, one turn, text and usage come back (53.595986ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (365.304662ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (226.989441ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (112.933314ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (227.476041ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.467088ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.312892ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.425816ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.242552ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.45962ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.337404ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.377686ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.338052ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.530913ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.794592ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.478666ms)
✔ gateway: op 9 resumable resumes (0.332182ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.763926ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.432209ms)
✔ gateway: close() is final and unparseable frames are ignored (0.382755ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (68.524866ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (63.94703ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (136.516427ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.527505ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (91.998853ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (88.154245ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (88.624448ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (186.984633ms)
✔ git: push pushes the named branch only and reports up to date (63.999475ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (81.504909ms)
✔ git: the credential helper answers get over https from a private file and nothing else (189.19431ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (752.117747ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (3.995149ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (84.923031ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.893848ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.096948ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (57.919952ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (335.710733ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.356444ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.858589ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.00731ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (39.905088ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (132.683955ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (78.784082ms)
✔ notices: a kind is recorded per UTC day and found again (0.42906ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.314265ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.029434ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.771295ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.344136ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (49.411468ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (45.986284ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (73.96153ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (631.660709ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.746742ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.212156ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.81499ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.734504ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.931204ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.372629ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.548172ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (2.40931ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.034413ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (32.390237ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (16.599589ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.916278ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (3.815668ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.748293ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.21768ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1015.152052ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.428072ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.8981ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.208583ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.203371ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.229417ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (7.811889ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.532553ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.825032ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.437295ms)
✔ tools: listing and search caps hold (15.185943ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.963757ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.725773ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.280921ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.069305ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.297714ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.666493ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.866652ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.112619ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.209453ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1029.690379ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.100777ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.217931ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.551203ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.197871ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 21848.550915
✖ failing tests:
test at packages/cli/tests/host.test.mjs:271:1
✖ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (141.222031ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: write EPIPE
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:279:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: write EPIPE
at epipe (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:113:24)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:276:51
at ChildProcess.send (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:94:23)
at startHost (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/host.mjs:150:36)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async waitForActual (node:assert:615:5)
at async strict.rejects (node:assert:738:25)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:279:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7),
operator: 'rejects',
diff: 'simple'
}
@@ -0,0 +1,272 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (30.190261ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.551942ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (19.199556ms)
✔ decide prints a declining choice as declining (17.919345ms)
✔ an unknown outcome is reported once and never resent (21.964825ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (26.414966ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.731365ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (23.234187ms)
✔ every human command refuses inside an agent run before it touches the bus (18.288267ms)
✔ usage errors exit 4; no business and no host is a usage error (21.811173ms)
✔ agents and tasks print through the broker (16.927124ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.522903ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (77.396725ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (60.485574ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (56.355736ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (63.169257ms)
✔ a business without tracker.baseUrl gets no trackers entry (60.166926ms)
✔ an unknown business and a broken system config refuse with exit 3 (95.495542ms)
✔ empty views say so (1.140766ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.363868ms)
✔ tasks print the tracker fields the snapshot carries (0.220362ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (971.553376ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (159.429205ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (77.209615ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (129.794763ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (125.291949ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (87.645136ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (121.483554ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (90.276229ms)
✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (138.303819ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (25.158056ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.221938ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (214.257266ms)
✔ bus start refuses with exit 3 without a notifier config (87.538076ms)
✔ bus start runs until bus stop; status reports it while it runs (604.522432ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.482392ms)
✔ zoned uses the IANA zone across DST (27.362837ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (33.004841ms)
✔ two blocking decisions get two DMs with different nonces (21.768645ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.421146ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.006717ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (27.839774ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.77886ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (25.041516ms)
✔ 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 (161.21144ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (61.112773ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (20.296917ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (11.449409ms)
✔ an inbox read failure is logged and the next poll retries (0.869409ms)
✔ no Discord id reaches the journal or the log (10.32983ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.884583ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.513173ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.403405ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.464089ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.587224ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.591962ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.388675ms)
✔ 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.545456ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.429635ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.477638ms)
✔ digest content stays within Discord's 2000 characters (0.340235ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.375699ms)
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 (372.337838ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (57.246794ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2297.274981ms)
✔ busExit and refuseInsideAgent (0.421121ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.030284ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (2.102798ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.552857ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.517368ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (18.089251ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (8.925536ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.027423ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.33504ms)
✔ authorize: open channel, listed user (1.931272ms)
✔ authorize: wrong guild (0.189982ms)
✔ authorize: no guild (DM) (0.157779ms)
✔ authorize: unlisted channel (0.18683ms)
✔ authorize: unknown channel, no info (0.146678ms)
✔ authorize: thread of listed parent (0.197969ms)
✔ authorize: thread of unlisted parent (0.145916ms)
✔ authorize: text channel that is not a thread and not listed (0.122708ms)
✔ authorize: unlisted user (0.183533ms)
✔ authorize: no author (0.264893ms)
✔ authorize: bot author (listed id, bot flag) (0.259968ms)
✔ authorize: system author (0.14774ms)
✔ authorize: the bot itself (0.116946ms)
✔ authorize: webhook (0.106236ms)
✔ authorize: mention channel without mention (0.150081ms)
✔ authorize: mention channel with bot mention (0.176439ms)
✔ authorize: mention channel with @everyone only (0.499201ms)
✔ authorize: mention channel mentioning someone else (0.110156ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.122991ms)
✔ authorize: private thread under mention channel, mentioned (0.3239ms)
✔ authorize: private thread under mention channel, not mentioned (0.096298ms)
✔ authorize: thread in another guild per channel info (0.075588ms)
✔ authorize: not an object (0.062351ms)
✔ authorize: no id (0.06053ms)
✔ authorize: oversize content is accepted and flagged (0.073567ms)
✔ authorize: exactly the limit is not oversize (0.058582ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.485945ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.152046ms)
✔ binding: a complete binding validates and is frozen (3.425684ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (2.141945ms)
✔ binding: empty allowlists refuse (0.590751ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.751669ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.988316ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.730047ms)
✔ binding: file must be 0600, regular, not a symlink (1.795508ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.241415ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (116.502152ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (4.661386ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (422.471569ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (214.927913ms)
✔ cli: run refuses when STOP is present, before any network use (150.721805ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.951144ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.570816ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.646576ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.812594ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.558125ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.177876ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.170751ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.6356ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.578277ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.157117ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.54556ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.128202ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.80801ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (34.412926ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.548021ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.511124ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.63614ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.130849ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.028945ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.862027ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.820621ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.905224ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.344559ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.159632ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.504756ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.422853ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.883512ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.965183ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.334097ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.562132ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.225122ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.667398ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.600949ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.441715ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (63.624535ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (57.05796ms)
✔ engine: one prompt, one turn, text and usage come back (61.662112ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (366.995448ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (235.440028ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.064014ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (231.265489ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (127.256069ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.85529ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (613.568922ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.335897ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.525471ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.342611ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.113013ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (47.5138ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.318652ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.510866ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.491385ms)
✔ gateway: op 9 resumable resumes (0.353346ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.836841ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.54704ms)
✔ gateway: close() is final and unparseable frames are ignored (0.61774ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (75.467916ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (77.217847ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (134.680187ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.483544ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (99.008648ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (90.548843ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (92.799709ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (229.565788ms)
✔ git: push pushes the named branch only and reports up to date (92.029415ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (97.074074ms)
✔ git: the credential helper answers get over https from a private file and nothing else (204.321635ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (743.152909ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.713142ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (96.468494ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.607066ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.301534ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (70.262641ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (336.611061ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.39441ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (24.448638ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.485653ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (49.85054ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (154.040521ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (103.798693ms)
✔ notices: a kind is recorded per UTC day and found again (0.644128ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.043981ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.403883ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.16611ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (61.108012ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (61.196009ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (46.242697ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (75.100233ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (699.071346ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.419085ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.005535ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.613088ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.674523ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.041611ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.097134ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.337198ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (2.203316ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.903674ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (26.819093ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.05144ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.018042ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.178702ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.773509ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.541534ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1016.06891ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.427365ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.948754ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.180273ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.199006ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.337322ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.560483ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.837649ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.925993ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.759524ms)
✔ tools: listing and search caps hold (10.773015ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.929124ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.225338ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.293259ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.213271ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.591889ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.975545ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.905601ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.360302ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.263509ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1027.728519ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.167855ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.226639ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.576987ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.185515ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 22115.416853
✖ failing tests:
test at packages/cli/tests/host.test.mjs:301:1
✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (138.303819ms)
Error: write EPIPE
at epipe (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:113:24)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:307:71
at ChildProcess.send (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:94:23)
at Object.close (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/host.mjs:184:36)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:310:27)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'EPIPE',
errno: -32,
syscall: 'write'
}
@@ -0,0 +1,272 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (27.725004ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.861808ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (28.564691ms)
✔ decide prints a declining choice as declining (25.178783ms)
✔ an unknown outcome is reported once and never resent (15.039452ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.756226ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (19.747332ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.956081ms)
✔ every human command refuses inside an agent run before it touches the bus (21.351565ms)
✔ usage errors exit 4; no business and no host is a usage error (18.257531ms)
✔ agents and tasks print through the broker (14.04227ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.094164ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (73.122818ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (55.622341ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (63.195833ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (56.787227ms)
✔ a business without tracker.baseUrl gets no trackers entry (56.402734ms)
✔ an unknown business and a broken system config refuse with exit 3 (89.866092ms)
✔ empty views say so (0.866121ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.127362ms)
✔ tasks print the tracker fields the snapshot carries (0.255718ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (967.30061ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (150.187073ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (78.707426ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (127.111373ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (129.814801ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (78.327248ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (133.345574ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.733107ms)
✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (133.706712ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.956798ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.478231ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (204.490025ms)
✔ bus start refuses with exit 3 without a notifier config (91.936624ms)
✔ bus start runs until bus stop; status reports it while it runs (599.011152ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.458927ms)
✔ zoned uses the IANA zone across DST (25.33944ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (35.749269ms)
✔ two blocking decisions get two DMs with different nonces (24.558137ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (1.37865ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (19.234281ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (34.395104ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.592443ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (19.345267ms)
✔ 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 (142.375961ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (59.048147ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.977223ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.041438ms)
✔ an inbox read failure is logged and the next poll retries (0.798456ms)
✔ no Discord id reaches the journal or the log (10.086742ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.456131ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.563806ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.348498ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.498688ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.574324ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.095986ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.376607ms)
✔ 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.564945ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.436412ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.4563ms)
✔ digest content stays within Discord's 2000 characters (0.417583ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.049151ms)
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 (372.651182ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (62.998163ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2274.624121ms)
✔ busExit and refuseInsideAgent (3.612584ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.003148ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.443646ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.543017ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.584522ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.38783ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.810789ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (6.60538ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.270888ms)
✔ authorize: open channel, listed user (1.81043ms)
✔ authorize: wrong guild (0.186899ms)
✔ authorize: no guild (DM) (0.179106ms)
✔ authorize: unlisted channel (0.198354ms)
✔ authorize: unknown channel, no info (0.153258ms)
✔ authorize: thread of listed parent (0.195673ms)
✔ authorize: thread of unlisted parent (0.1863ms)
✔ authorize: text channel that is not a thread and not listed (0.125952ms)
✔ authorize: unlisted user (0.257968ms)
✔ authorize: no author (0.253408ms)
✔ authorize: bot author (listed id, bot flag) (0.910052ms)
✔ authorize: system author (0.226458ms)
✔ authorize: the bot itself (0.090903ms)
✔ authorize: webhook (0.086991ms)
✔ authorize: mention channel without mention (0.119171ms)
✔ authorize: mention channel with bot mention (0.140139ms)
✔ authorize: mention channel with @everyone only (0.104111ms)
✔ authorize: mention channel mentioning someone else (0.088684ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.124394ms)
✔ authorize: private thread under mention channel, mentioned (0.100233ms)
✔ authorize: private thread under mention channel, not mentioned (0.072504ms)
✔ authorize: thread in another guild per channel info (0.091162ms)
✔ authorize: not an object (0.06125ms)
✔ authorize: no id (0.059814ms)
✔ authorize: oversize content is accepted and flagged (0.061261ms)
✔ authorize: exactly the limit is not oversize (0.063628ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.461563ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.141433ms)
✔ binding: a complete binding validates and is frozen (2.477734ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.305401ms)
✔ binding: empty allowlists refuse (0.329257ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.110092ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.344477ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.450987ms)
✔ binding: file must be 0600, regular, not a symlink (1.523266ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.334094ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (115.303312ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (4.802188ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (391.525902ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (180.216922ms)
✔ cli: run refuses when STOP is present, before any network use (138.675771ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.874097ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.095084ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.792175ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.836111ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.616706ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.202123ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.529595ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.5809ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.199597ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.930766ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.210198ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.108887ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.637519ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.43494ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.552117ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.27629ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.204142ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.167342ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.837091ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.494918ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.722664ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.707717ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.258541ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.610759ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.113588ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.848003ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.764722ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.860669ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.164086ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.391768ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.456197ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.982474ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.511074ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.405558ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (60.224358ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.874251ms)
✔ engine: one prompt, one turn, text and usage come back (54.732804ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (370.960466ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (229.920416ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.197407ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.740568ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (128.697407ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (214.476669ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.00822ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.349876ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.482971ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.004547ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.437217ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (48.025521ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.418776ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.587165ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.958786ms)
✔ gateway: op 9 resumable resumes (0.316817ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.896081ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.53155ms)
✔ gateway: close() is final and unparseable frames are ignored (0.719708ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (72.577945ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (64.073997ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (136.680182ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.466574ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (98.52451ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (89.332896ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (83.826305ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (198.687787ms)
✔ git: push pushes the named branch only and reports up to date (70.744788ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (80.054235ms)
✔ git: the credential helper answers get over https from a private file and nothing else (207.681334ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (758.537557ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.8765ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (100.147953ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (2.902376ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (6.615975ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (56.645221ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (329.179783ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.39827ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (24.821806ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.165319ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (46.28033ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (141.204747ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (82.427663ms)
✔ notices: a kind is recorded per UTC day and found again (0.411306ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.321794ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.193377ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.956844ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (49.026651ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (51.93831ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (58.71983ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (77.918761ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (639.543651ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.402223ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (6.070935ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.621755ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.720534ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.812191ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (8.506332ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.353446ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.566329ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.108639ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (27.257144ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (10.800698ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.090073ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.561691ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.673874ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.623866ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1015.500646ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.424022ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.004408ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.212984ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.226511ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.263028ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.407763ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.480263ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (6.143727ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.402021ms)
✔ tools: listing and search caps hold (8.754398ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.858684ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.786359ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.46187ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.253895ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.957183ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.82702ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.767986ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.611241ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.410561ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1028.827543ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.196143ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.235084ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.83305ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.244223ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 22074.480532
✖ failing tests:
test at packages/cli/tests/host.test.mjs:301:1
✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (133.706712ms)
Error: write EPIPE
at epipe (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:113:24)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:307:71
at ChildProcess.send (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:94:23)
at Object.close (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/host.mjs:188:34)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:310:16)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'EPIPE',
errno: -32,
syscall: 'write'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (22.726199ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (40.099193ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (18.669567ms)
✔ decide prints a declining choice as declining (25.574997ms)
✔ an unknown outcome is reported once and never resent (16.609845ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (18.22855ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (24.848441ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.341316ms)
✔ every human command refuses inside an agent run before it touches the bus (31.557999ms)
✔ usage errors exit 4; no business and no host is a usage error (21.149079ms)
✔ agents and tasks print through the broker (21.441955ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.723751ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (67.432148ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (60.256213ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (61.543467ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (57.032146ms)
✔ a business without tracker.baseUrl gets no trackers entry (68.288178ms)
✔ an unknown business and a broken system config refuse with exit 3 (84.188563ms)
✔ empty views say so (0.981318ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.374963ms)
✔ tasks print the tracker fields the snapshot carries (0.344917ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (978.255238ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (138.645401ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (72.426735ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (125.339275ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (120.370682ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (68.565388ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (121.631302ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.252395ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (133.43766ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (28.224116ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.350116ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (197.47672ms)
✔ bus start refuses with exit 3 without a notifier config (85.754313ms)
✔ bus start runs until bus stop; status reports it while it runs (594.820116ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.981795ms)
✔ zoned uses the IANA zone across DST (31.364199ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (25.098743ms)
✔ two blocking decisions get two DMs with different nonces (24.076035ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.355916ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (22.682652ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (26.736546ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (28.784885ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (22.541675ms)
✔ 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 (158.299538ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (56.517676ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.572217ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.163424ms)
✔ an inbox read failure is logged and the next poll retries (0.713737ms)
✔ no Discord id reaches the journal or the log (12.146805ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.384862ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.726507ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.314435ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.671033ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.648588ms)
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.368848ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.426037ms)
✔ 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.586933ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.403667ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.50309ms)
✔ digest content stays within Discord's 2000 characters (0.284395ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.786515ms)
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 (377.492026ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (73.721744ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2284.728472ms)
✔ busExit and refuseInsideAgent (0.411573ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.119289ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.484911ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.557944ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.490382ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (16.016332ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.648142ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.801007ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.466221ms)
✔ authorize: open channel, listed user (1.495214ms)
✔ authorize: wrong guild (0.184132ms)
✔ authorize: no guild (DM) (0.158573ms)
✔ authorize: unlisted channel (0.187997ms)
✔ authorize: unknown channel, no info (0.172406ms)
✔ authorize: thread of listed parent (0.206847ms)
✔ authorize: thread of unlisted parent (0.136909ms)
✔ authorize: text channel that is not a thread and not listed (0.135584ms)
✔ authorize: unlisted user (0.182579ms)
✔ authorize: no author (0.259783ms)
✔ authorize: bot author (listed id, bot flag) (0.151355ms)
✔ authorize: system author (0.13244ms)
✔ authorize: the bot itself (0.09681ms)
✔ authorize: webhook (0.124532ms)
✔ authorize: mention channel without mention (0.180748ms)
✔ authorize: mention channel with bot mention (3.388141ms)
✔ authorize: mention channel with @everyone only (0.246928ms)
✔ authorize: mention channel mentioning someone else (0.100541ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.091404ms)
✔ authorize: private thread under mention channel, mentioned (0.106613ms)
✔ authorize: private thread under mention channel, not mentioned (0.077901ms)
✔ authorize: thread in another guild per channel info (0.075693ms)
✔ authorize: not an object (0.077901ms)
✔ authorize: no id (0.069592ms)
✔ authorize: oversize content is accepted and flagged (0.074525ms)
✔ authorize: exactly the limit is not oversize (0.068144ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.544098ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.174489ms)
✔ binding: a complete binding validates and is frozen (2.666765ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.251329ms)
✔ binding: empty allowlists refuse (0.367955ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.247392ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.852648ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.231469ms)
✔ binding: file must be 0600, regular, not a symlink (1.680945ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.274394ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (108.009805ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.019763ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (408.305407ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (198.458301ms)
✔ cli: run refuses when STOP is present, before any network use (119.546867ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.185243ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.101053ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (20.666651ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.91693ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.482484ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.16619ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.390299ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.838456ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.60475ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.143404ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.880149ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.824679ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.245108ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.364214ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.800024ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (6.313398ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.949918ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (9.378475ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.77505ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.826286ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (5.029445ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.002168ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.599938ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.425683ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (7.105805ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.39627ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.730082ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.790504ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.175493ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.460952ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.103831ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.771749ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.603922ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.431817ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (50.800684ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (59.155256ms)
✔ engine: one prompt, one turn, text and usage come back (56.618599ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (351.382076ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (235.187205ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (112.804001ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (226.221691ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.729691ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.9388ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.299749ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.274359ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.955363ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (429.833023ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.641738ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (43.387829ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.294965ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.623289ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.473724ms)
✔ gateway: op 9 resumable resumes (0.338716ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.839054ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.486022ms)
✔ gateway: close() is final and unparseable frames are ignored (0.50621ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (68.972907ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (67.428241ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (154.569672ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.470823ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (96.889348ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (89.306211ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (90.186108ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (198.413848ms)
✔ git: push pushes the named branch only and reports up to date (62.187887ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (75.702707ms)
✔ git: the credential helper answers get over https from a private file and nothing else (188.888624ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (731.68452ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.796711ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (97.691222ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.596545ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.860128ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (64.069801ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (336.680712ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.409477ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.121286ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.945829ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (35.735586ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (142.138184ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (80.656688ms)
✔ notices: a kind is recorded per UTC day and found again (0.408453ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.32331ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.75518ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.184563ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (83.350178ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (55.261995ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (44.622915ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (71.811221ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (627.650094ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (4.382351ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (3.196873ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.656126ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.737402ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.79421ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.166104ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.553735ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (2.499601ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (5.618046ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (33.45824ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (14.305094ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.194548ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.053531ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.793119ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.206796ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1015.545737ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.432412ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.039886ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.194603ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.199809ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.23459ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (5.436958ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.085675ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.861864ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.887542ms)
✔ tools: listing and search caps hold (14.009015ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.219245ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.909847ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.444722ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.502471ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.795596ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.316308ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.824395ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.516548ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.433468ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1024.75181ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.250146ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.228631ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (3.02676ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.286185ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3104.897055
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:332:1
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.368848ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: at: {"at":"2026-10-08","kind":"dm","decision":"a","outcome":"confirmed","messageId":"1"}
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:361:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (24.158742ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (36.553469ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (18.076978ms)
✔ decide prints a declining choice as declining (24.162789ms)
✔ an unknown outcome is reported once and never resent (15.341305ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.762754ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.842692ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (16.674793ms)
✔ every human command refuses inside an agent run before it touches the bus (17.1648ms)
✔ usage errors exit 4; no business and no host is a usage error (20.246485ms)
✔ agents and tasks print through the broker (22.345053ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.083256ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (69.210037ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (55.212696ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (55.68185ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (57.395285ms)
✔ a business without tracker.baseUrl gets no trackers entry (66.335789ms)
✔ an unknown business and a broken system config refuse with exit 3 (81.547046ms)
✔ empty views say so (1.154157ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.286046ms)
✔ tasks print the tracker fields the snapshot carries (0.200556ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (963.804965ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (144.952239ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (78.042193ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (122.383088ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (120.271078ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.289915ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (118.88565ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.403813ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (135.474054ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.19171ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.699633ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (203.124317ms)
✔ bus start refuses with exit 3 without a notifier config (88.133209ms)
✔ bus start runs until bus stop; status reports it while it runs (602.377329ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.269352ms)
✔ zoned uses the IANA zone across DST (24.967725ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (24.332286ms)
✔ two blocking decisions get two DMs with different nonces (19.766757ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.35656ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (22.417887ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (24.46273ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (27.250478ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.451332ms)
✔ 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 (146.832363ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (68.028414ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.778487ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.677167ms)
✔ an inbox read failure is logged and the next poll retries (0.706508ms)
✔ no Discord id reaches the journal or the log (9.774456ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.082679ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.926156ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.240621ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.50467ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.591536ms)
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.443028ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.364006ms)
✔ 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.513258ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.383339ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.581415ms)
✔ digest content stays within Discord's 2000 characters (0.290206ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.30446ms)
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 (359.572088ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (53.589497ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2257.255025ms)
✔ busExit and refuseInsideAgent (0.469498ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.123227ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.639687ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.555273ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.465523ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.86442ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.901415ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (5.9026ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.03818ms)
✔ authorize: open channel, listed user (1.737541ms)
✔ authorize: wrong guild (0.192858ms)
✔ authorize: no guild (DM) (0.271658ms)
✔ authorize: unlisted channel (0.190478ms)
✔ authorize: unknown channel, no info (0.17056ms)
✔ authorize: thread of listed parent (0.196923ms)
✔ authorize: thread of unlisted parent (0.145197ms)
✔ authorize: text channel that is not a thread and not listed (0.130481ms)
✔ authorize: unlisted user (0.851026ms)
✔ authorize: no author (0.279706ms)
✔ authorize: bot author (listed id, bot flag) (0.133807ms)
✔ authorize: system author (0.118535ms)
✔ authorize: the bot itself (0.073182ms)
✔ authorize: webhook (0.083921ms)
✔ authorize: mention channel without mention (0.125346ms)
✔ authorize: mention channel with bot mention (0.707573ms)
✔ authorize: mention channel with @everyone only (0.175719ms)
✔ authorize: mention channel mentioning someone else (0.167736ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.096688ms)
✔ authorize: private thread under mention channel, mentioned (0.090619ms)
✔ authorize: private thread under mention channel, not mentioned (0.07697ms)
✔ authorize: thread in another guild per channel info (0.075341ms)
✔ authorize: not an object (0.067585ms)
✔ authorize: no id (0.061902ms)
✔ authorize: oversize content is accepted and flagged (0.068441ms)
✔ authorize: exactly the limit is not oversize (0.061633ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.442616ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.149945ms)
✔ binding: a complete binding validates and is frozen (2.726218ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.352258ms)
✔ binding: empty allowlists refuse (0.383053ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.288902ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (2.280412ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.4798ms)
✔ binding: file must be 0600, regular, not a symlink (1.927939ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.551198ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (109.193081ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.05798ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (391.396888ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (184.213524ms)
✔ cli: run refuses when STOP is present, before any network use (146.290668ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.149828ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.245039ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (18.063291ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.092513ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.651459ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.204628ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.470906ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.732986ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.235227ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.081434ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.190745ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.666723ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.90144ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.083308ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (9.88305ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.109129ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (5.294589ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.460322ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.066995ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.987533ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (9.087361ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.278507ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.432537ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.228616ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.262337ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.639558ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.86637ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.510851ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.195851ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.346978ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.187305ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.643748ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.594534ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.408496ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (52.577893ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.071115ms)
✔ engine: one prompt, one turn, text and usage come back (55.303446ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (365.328274ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (235.660705ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.173269ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (227.480275ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (126.243511ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.363735ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.192422ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.329328ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.493796ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.018107ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.134374ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.391758ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (1.992776ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.647812ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.477697ms)
✔ gateway: op 9 resumable resumes (0.336546ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.767568ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.604046ms)
✔ gateway: close() is final and unparseable frames are ignored (0.560699ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (66.767761ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (65.891655ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (134.261181ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.34243ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (88.725305ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (78.848385ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (86.752223ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (193.898386ms)
✔ git: push pushes the named branch only and reports up to date (73.617059ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (81.155433ms)
✔ git: the credential helper answers get over https from a private file and nothing else (197.350031ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (737.929872ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.809675ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (94.882871ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (2.687739ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.687967ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (65.848385ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (322.078843ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.448333ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (23.107123ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.919341ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (36.127689ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (128.744857ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (87.145498ms)
✔ notices: a kind is recorded per UTC day and found again (0.409792ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.572224ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.508155ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.428227ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (56.614624ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (54.082125ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (44.075075ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (72.766226ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (637.796866ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.465114ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.732941ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.560541ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.717624ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.851678ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (10.954585ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.150362ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.683606ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.174145ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.628685ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (10.957921ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.512049ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.738692ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.659027ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.49639ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1015.739149ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.445536ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (7.003404ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.284995ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.204298ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.291654ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.018174ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.054626ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.134532ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (5.138343ms)
✔ tools: listing and search caps hold (13.624938ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.974199ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.73413ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.163623ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (2.061201ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.958608ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.387374ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.633358ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.722956ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.477962ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1027.538954ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.499409ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.215807ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.858386ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.214932ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3100.5543
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:332:1
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (1.443028ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: decision: {"at":"2026-10-08T12:00:00.000Z","kind":"dm","decision":7,"outcome":"confirmed","messageId":"1"}
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:361:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (37.422929ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (37.071594ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (21.080584ms)
✔ decide prints a declining choice as declining (20.04342ms)
✔ an unknown outcome is reported once and never resent (14.804159ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (22.098609ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.347239ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (18.919893ms)
✔ every human command refuses inside an agent run before it touches the bus (17.236435ms)
✔ usage errors exit 4; no business and no host is a usage error (17.689522ms)
✔ agents and tasks print through the broker (15.65574ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.214317ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (82.349948ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.323357ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (61.117009ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (69.683038ms)
✔ a business without tracker.baseUrl gets no trackers entry (54.893936ms)
✔ an unknown business and a broken system config refuse with exit 3 (86.724411ms)
✔ empty views say so (1.085073ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.382591ms)
✔ tasks print the tracker fields the snapshot carries (0.23399ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (970.508565ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (147.889727ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (73.9945ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (120.130908ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (120.81105ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (70.561705ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (139.814459ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.086439ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (136.714994ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.234726ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.280724ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (201.883187ms)
✔ bus start refuses with exit 3 without a notifier config (92.117037ms)
✔ bus start runs until bus stop; status reports it while it runs (601.515435ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.052979ms)
✔ zoned uses the IANA zone across DST (26.821343ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (36.678724ms)
✔ two blocking decisions get two DMs with different nonces (27.439441ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.358875ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (23.631759ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (26.362556ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (31.38823ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (25.761153ms)
✔ 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 (148.316194ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (60.872804ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.323618ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.754695ms)
✔ an inbox read failure is logged and the next poll retries (0.769083ms)
✔ no Discord id reaches the journal or the log (10.734013ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.221893ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.613008ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.698013ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.634529ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.608086ms)
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (2.587832ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.407309ms)
✔ 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.675645ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.433644ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.475756ms)
✔ digest content stays within Discord's 2000 characters (0.282334ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.633409ms)
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 (405.859855ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (61.851914ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2262.673488ms)
✔ busExit and refuseInsideAgent (0.429279ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.418979ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.936612ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.5768ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.530584ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.192158ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.118732ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.146959ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (3.561858ms)
✔ authorize: open channel, listed user (1.809982ms)
✔ authorize: wrong guild (0.194538ms)
✔ authorize: no guild (DM) (0.178467ms)
✔ authorize: unlisted channel (0.181147ms)
✔ authorize: unknown channel, no info (0.329193ms)
✔ authorize: thread of listed parent (0.193682ms)
✔ authorize: thread of unlisted parent (0.159838ms)
✔ authorize: text channel that is not a thread and not listed (0.144147ms)
✔ authorize: unlisted user (0.199061ms)
✔ authorize: no author (0.543357ms)
✔ authorize: bot author (listed id, bot flag) (0.255464ms)
✔ authorize: system author (0.131552ms)
✔ authorize: the bot itself (0.101056ms)
✔ authorize: webhook (0.098724ms)
✔ authorize: mention channel without mention (0.137969ms)
✔ authorize: mention channel with bot mention (0.13531ms)
✔ authorize: mention channel with @everyone only (0.163329ms)
✔ authorize: mention channel mentioning someone else (0.086988ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.08537ms)
✔ authorize: private thread under mention channel, mentioned (0.084608ms)
✔ authorize: private thread under mention channel, not mentioned (0.09232ms)
✔ authorize: thread in another guild per channel info (0.086332ms)
✔ authorize: not an object (0.069381ms)
✔ authorize: no id (0.080801ms)
✔ authorize: oversize content is accepted and flagged (0.077372ms)
✔ authorize: exactly the limit is not oversize (0.073449ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.485697ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.154697ms)
✔ binding: a complete binding validates and is frozen (3.103639ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.886486ms)
✔ binding: empty allowlists refuse (0.455986ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.524388ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.698656ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.462978ms)
✔ binding: file must be 0600, regular, not a symlink (1.364085ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.505284ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (115.667508ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.155697ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (399.506418ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (182.648547ms)
✔ cli: run refuses when STOP is present, before any network use (132.281894ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.772792ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.187514ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.853958ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (22.563378ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.520334ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.344843ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.665819ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (4.529538ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.448457ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.821618ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.227141ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.444179ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.244218ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.377918ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (12.104754ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.519883ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.140638ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (4.799755ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.643622ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.732274ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.684933ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.521755ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.067725ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.448702ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.310496ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.566141ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.932251ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.881546ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.439167ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.427123ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (3.182141ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (2.184927ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.726063ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.412464ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (57.032018ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (53.417567ms)
✔ engine: one prompt, one turn, text and usage come back (47.006518ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (360.320218ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (229.716776ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (114.522026ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (225.499766ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (126.961635ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.478207ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.178135ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.428943ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.543708ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.549809ms)
✔ engine: a malformed JSONL line fails the turn, not the process (28.379874ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (47.340845ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.649166ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.623281ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.548057ms)
✔ gateway: op 9 resumable resumes (0.34792ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.935919ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.567601ms)
✔ gateway: close() is final and unparseable frames are ignored (0.463194ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (67.664274ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (68.192463ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (131.318957ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.461261ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (98.992052ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (86.410784ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (82.56538ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (191.505094ms)
✔ git: push pushes the named branch only and reports up to date (68.717608ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (84.934ms)
✔ git: the credential helper answers get over https from a private file and nothing else (193.056389ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (744.784228ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.916805ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (92.126751ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.22946ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.505797ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (69.861407ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (342.848681ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.445998ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (21.897438ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.982212ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (41.258927ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (127.366567ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (89.381422ms)
✔ notices: a kind is recorded per UTC day and found again (0.695283ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.377191ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (4.412125ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.892844ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.118544ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (54.352578ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (45.339287ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (86.40136ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (641.518432ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.520334ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (9.428935ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (1.259518ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (3.51936ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.871197ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.934274ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.090368ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.636415ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.098729ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (27.439635ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (15.747362ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.69182ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.387431ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.80604ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.718797ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1011.014195ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.426891ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.968259ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.28499ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.208434ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.371375ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.890958ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.670054ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (4.777472ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.413435ms)
✔ tools: listing and search caps hold (10.026553ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.642266ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.26989ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.340782ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.329359ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.961826ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (4.569406ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.056576ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.951236ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.425029ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1031.295405ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.111749ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.233388ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.825682ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.191929ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3159.188008
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:332:1
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (2.587832ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: status: {"at":"2026-10-08T12:00:00.000Z","kind":"dm","decision":"a","outcome":"refused","messageId":null,"status":"403"}
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:361:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.604208ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (40.054035ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (21.764354ms)
✔ decide prints a declining choice as declining (18.885385ms)
✔ an unknown outcome is reported once and never resent (12.460984ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.301957ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (14.624948ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.11494ms)
✔ every human command refuses inside an agent run before it touches the bus (13.866522ms)
✔ usage errors exit 4; no business and no host is a usage error (28.735889ms)
✔ agents and tasks print through the broker (26.540393ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.085676ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (76.076506ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (57.037896ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (63.969567ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (66.097615ms)
✔ a business without tracker.baseUrl gets no trackers entry (72.570193ms)
✔ an unknown business and a broken system config refuse with exit 3 (82.532869ms)
✔ empty views say so (1.224819ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (2.068407ms)
✔ tasks print the tracker fields the snapshot carries (0.254337ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (981.60008ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (157.257907ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (86.390523ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (132.002306ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (126.289403ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (81.164738ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (136.059927ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (69.451464ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (132.250025ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.799123ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.306365ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (210.88687ms)
✔ bus start refuses with exit 3 without a notifier config (86.041721ms)
✔ bus start runs until bus stop; status reports it while it runs (596.152761ms)
✔ bus-service.sh renders the unit and installs it into a given directory (28.251643ms)
✔ zoned uses the IANA zone across DST (30.087786ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (45.403788ms)
✔ two blocking decisions get two DMs with different nonces (26.393587ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.466722ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.101786ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (22.273066ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (21.487198ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (21.779764ms)
✔ 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 (149.889362ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (66.436359ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (17.96293ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.987668ms)
✔ an inbox read failure is logged and the next poll retries (0.761583ms)
✔ no Discord id reaches the journal or the log (10.380825ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (1.978632ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.105246ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.34651ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.511658ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.651439ms)
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (2.210922ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.401803ms)
✔ 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.517836ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.467409ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.481863ms)
✔ digest content stays within Discord's 2000 characters (0.290913ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.78117ms)
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 (404.261782ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (72.775506ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2268.694712ms)
✔ busExit and refuseInsideAgent (3.5789ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.540198ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.725294ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.635284ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.491455ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (20.224505ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.8625ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.525788ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.473542ms)
✔ authorize: open channel, listed user (1.820629ms)
✔ authorize: wrong guild (0.202127ms)
✔ authorize: no guild (DM) (0.168119ms)
✔ authorize: unlisted channel (0.222404ms)
✔ authorize: unknown channel, no info (0.174177ms)
✔ authorize: thread of listed parent (0.196227ms)
✔ authorize: thread of unlisted parent (0.183736ms)
✔ authorize: text channel that is not a thread and not listed (0.155604ms)
✔ authorize: unlisted user (0.213546ms)
✔ authorize: no author (0.942744ms)
✔ authorize: bot author (listed id, bot flag) (0.151721ms)
✔ authorize: system author (0.115806ms)
✔ authorize: the bot itself (0.076729ms)
✔ authorize: webhook (0.094283ms)
✔ authorize: mention channel without mention (0.218164ms)
✔ authorize: mention channel with bot mention (0.170093ms)
✔ authorize: mention channel with @everyone only (0.097306ms)
✔ authorize: mention channel mentioning someone else (0.075246ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.13087ms)
✔ authorize: private thread under mention channel, mentioned (0.105726ms)
✔ authorize: private thread under mention channel, not mentioned (0.082724ms)
✔ authorize: thread in another guild per channel info (0.084219ms)
✔ authorize: not an object (0.064336ms)
✔ authorize: no id (0.069861ms)
✔ authorize: oversize content is accepted and flagged (0.386853ms)
✔ authorize: exactly the limit is not oversize (0.071995ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.426175ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.147486ms)
✔ binding: a complete binding validates and is frozen (2.480336ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.28277ms)
✔ binding: empty allowlists refuse (0.391807ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.343858ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (2.091459ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.496759ms)
✔ binding: file must be 0600, regular, not a symlink (2.598319ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.473508ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (121.074688ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.12547ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (460.408864ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (201.545322ms)
✔ cli: run refuses when STOP is present, before any network use (140.954891ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.972347ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.18554ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (20.895686ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.94536ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.709978ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.452756ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.704967ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.226804ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.267094ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.551428ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (2.093712ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.285814ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.717728ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.575061ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.80267ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (8.43569ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (8.428094ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (10.332193ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (7.290513ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.968591ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (9.896868ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.438948ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (3.944514ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.264162ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (3.860405ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.50394ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.781343ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.848896ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.161653ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.496068ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.448818ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.866369ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.773121ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.485371ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (55.57274ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (51.366865ms)
✔ engine: one prompt, one turn, text and usage come back (49.824237ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (383.049001ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (255.898307ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (105.220681ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.931619ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.909235ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.316315ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.239112ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.391394ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.516072ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (427.235616ms)
✔ engine: a malformed JSONL line fails the turn, not the process (27.385271ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.991947ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.440667ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.602604ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.524766ms)
✔ gateway: op 9 resumable resumes (0.301042ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.831424ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.573224ms)
✔ gateway: close() is final and unparseable frames are ignored (0.510912ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (75.714083ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (72.684299ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (152.536589ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.445177ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (86.357762ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (139.426177ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (96.914383ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (204.70006ms)
✔ git: push pushes the named branch only and reports up to date (72.011335ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (95.687033ms)
✔ git: the credential helper answers get over https from a private file and nothing else (213.624448ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (770.360271ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (6.599884ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (88.898613ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (2.372596ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (11.477013ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (86.475051ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (381.975631ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.440673ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (24.912468ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.058676ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (39.512903ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (148.189775ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (94.252275ms)
✔ notices: a kind is recorded per UTC day and found again (0.432961ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.87512ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.358779ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.505854ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (71.220699ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (48.053437ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (40.728666ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (86.81428ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (719.30105ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.451802ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (6.109889ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.694139ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.755482ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.392521ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (8.251677ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (5.351041ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.934493ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (8.265875ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (45.998311ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.958637ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.131065ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (3.483135ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.545878ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (2.431714ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1010.812178ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.44209ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.052799ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.249708ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.205669ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.43342ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.517176ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (3.670294ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.015399ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.081116ms)
✔ tools: listing and search caps hold (11.352476ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.960546ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.540842ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.286092ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.754676ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.392339ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.992815ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.878643ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.88394ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.271061ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1029.910195ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.099399ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.216198ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.522492ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.213869ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3192.79447
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:332:1
✖ the journal: a line with a wrong type refuses with exit 3 and names the field (2.210922ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: messageId: {"at":"2026-10-08T12:00:00.000Z","kind":"dm","decision":"a","outcome":"confirmed","messageId":null}
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:361:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,281 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.46118ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (42.718341ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.250013ms)
✔ decide prints a declining choice as declining (22.384696ms)
✔ an unknown outcome is reported once and never resent (17.567777ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.172845ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.050554ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (16.2269ms)
✔ every human command refuses inside an agent run before it touches the bus (20.88717ms)
✔ usage errors exit 4; no business and no host is a usage error (21.971726ms)
✔ agents and tasks print through the broker (18.828716ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.666498ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (80.650649ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (63.900192ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (65.390021ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (59.739908ms)
✔ a business without tracker.baseUrl gets no trackers entry (65.909697ms)
✔ an unknown business and a broken system config refuse with exit 3 (105.349397ms)
✔ empty views say so (1.182027ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.557529ms)
✔ tasks print the tracker fields the snapshot carries (0.300621ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (998.628257ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (147.164935ms)
✖ a second host for the same data root refuses with exit 3 while the first runs (122.303686ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (124.107194ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (127.702424ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (72.658936ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (129.777837ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (85.684017ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (135.70576ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.4901ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.67338ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (204.802721ms)
✔ bus start refuses with exit 3 without a notifier config (84.46603ms)
✔ bus start runs until bus stop; status reports it while it runs (593.406958ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.651811ms)
✔ zoned uses the IANA zone across DST (36.475422ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (37.313587ms)
✔ two blocking decisions get two DMs with different nonces (30.440144ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.315972ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (26.519409ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (27.850237ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (31.924286ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (21.392784ms)
✔ 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 (159.653661ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (57.455517ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (17.40533ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (14.568724ms)
✔ an inbox read failure is logged and the next poll retries (1.069928ms)
✔ no Discord id reaches the journal or the log (10.98208ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.975297ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.591215ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.807701ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.559725ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.637835ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.080466ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.373419ms)
✔ 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.515202ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.397955ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.557113ms)
✔ digest content stays within Discord's 2000 characters (0.29972ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.543221ms)
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 (394.176938ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (63.787445ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2296.025495ms)
✔ busExit and refuseInsideAgent (0.395528ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.279447ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (2.185478ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (1.004833ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.574398ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (25.971319ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (9.048344ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.291885ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.194355ms)
✔ authorize: open channel, listed user (2.001367ms)
✔ authorize: wrong guild (0.186392ms)
✔ authorize: no guild (DM) (0.350043ms)
✔ authorize: unlisted channel (0.200012ms)
✔ authorize: unknown channel, no info (0.277042ms)
✔ authorize: thread of listed parent (0.230679ms)
✔ authorize: thread of unlisted parent (0.244729ms)
✔ authorize: text channel that is not a thread and not listed (0.11864ms)
✔ authorize: unlisted user (0.451057ms)
✔ authorize: no author (0.31611ms)
✔ authorize: bot author (listed id, bot flag) (0.164631ms)
✔ authorize: system author (0.132288ms)
✔ authorize: the bot itself (0.105121ms)
✔ authorize: webhook (0.107934ms)
✔ authorize: mention channel without mention (0.144878ms)
✔ authorize: mention channel with bot mention (0.14995ms)
✔ authorize: mention channel with @everyone only (0.154474ms)
✔ authorize: mention channel mentioning someone else (0.083761ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.09336ms)
✔ authorize: private thread under mention channel, mentioned (0.088262ms)
✔ authorize: private thread under mention channel, not mentioned (0.078244ms)
✔ authorize: thread in another guild per channel info (0.078727ms)
✔ authorize: not an object (0.064903ms)
✔ authorize: no id (0.069133ms)
✔ authorize: oversize content is accepted and flagged (0.075616ms)
✔ authorize: exactly the limit is not oversize (0.07111ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.484811ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.15209ms)
✔ binding: a complete binding validates and is frozen (4.386481ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.332464ms)
✔ binding: empty allowlists refuse (0.384017ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.361972ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.611248ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.471599ms)
✔ binding: file must be 0600, regular, not a symlink (1.524219ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.491878ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (117.338765ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (1.868015ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (456.667007ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (227.116343ms)
✔ cli: run refuses when STOP is present, before any network use (134.077829ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.57958ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.033987ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.250477ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.534602ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.431492ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.092744ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.22347ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.101801ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.797309ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.029288ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (2.201328ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (2.207303ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.644949ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.442696ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.276767ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (8.763162ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.871857ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.48732ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (5.637294ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.755381ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.301092ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (6.07665ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.313828ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (5.279328ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.765377ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.645274ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.8501ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.036411ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.431338ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.669957ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (2.7424ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (2.839234ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.693109ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.438378ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (71.171023ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (57.380647ms)
✔ engine: one prompt, one turn, text and usage come back (63.787084ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (353.115324ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (231.132813ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (106.436515ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.172827ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (129.11987ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.654554ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (613.640007ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.333944ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.501024ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (432.123477ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.496899ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.848984ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.394024ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.830966ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.490513ms)
✔ gateway: op 9 resumable resumes (0.49192ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.884644ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.577702ms)
✔ gateway: close() is final and unparseable frames are ignored (0.421364ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (89.329351ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (86.724307ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (158.04282ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.389776ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (98.743587ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (97.876574ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (117.800032ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (206.476086ms)
✔ git: push pushes the named branch only and reports up to date (64.613298ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (85.682347ms)
✔ git: the credential helper answers get over https from a private file and nothing else (205.979349ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (742.056346ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.684078ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (85.196494ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (2.453684ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.724732ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (69.614151ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (412.764472ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.505546ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.408259ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.959767ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (45.763369ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (150.962069ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (82.927413ms)
✔ notices: a kind is recorded per UTC day and found again (0.499479ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.168334ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.529865ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.681754ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (51.558816ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (58.749216ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (60.119927ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (107.654965ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (705.801846ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.448901ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (4.496282ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.72985ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.817955ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.200875ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.755088ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.463876ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.681441ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.579708ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.73342ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.579184ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (10.366443ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (6.764733ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.934583ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.420706ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1014.873597ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.410526ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.014759ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.22341ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.199103ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.362706ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.947093ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.013743ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.087367ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.862621ms)
✔ tools: listing and search caps hold (12.164265ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.991574ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.601733ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.702669ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (2.210471ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (12.407443ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.805637ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.287762ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.500807ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.597459ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1033.147564ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.105647ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.217852ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.501422ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.148225ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3224.078743
✖ failing tests:
test at packages/cli/tests/host.test.mjs:191:1
✖ a second host for the same data root refuses with exit 3 while the first runs (122.303686ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
CliError: broker refused to start: startup-refused
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:201:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: CliError: broker refused to start: startup-refused
at startHost (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/host.mjs:128:11)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async waitForActual (node:assert:615:5)
at async strict.rejects (node:assert:738:25)
at async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:201:3)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7),
operator: 'rejects',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (32.785143ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (39.613318ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.116322ms)
✔ decide prints a declining choice as declining (21.236149ms)
✔ an unknown outcome is reported once and never resent (20.2856ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (18.166577ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (14.736747ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.823682ms)
✔ every human command refuses inside an agent run before it touches the bus (17.283709ms)
✔ usage errors exit 4; no business and no host is a usage error (17.842765ms)
✔ agents and tasks print through the broker (20.70521ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (4.501727ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (72.271384ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.171466ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (55.05432ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (59.84891ms)
✔ a business without tracker.baseUrl gets no trackers entry (62.183024ms)
✔ an unknown business and a broken system config refuse with exit 3 (109.443955ms)
✔ empty views say so (1.941581ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.55407ms)
✔ tasks print the tracker fields the snapshot carries (0.284878ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (983.756857ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (143.331175ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (79.253795ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (133.526182ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (130.050972ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.790364ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (124.285574ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (72.8748ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (137.061279ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.595937ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.620001ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.595149ms)
✔ bus start refuses with exit 3 without a notifier config (84.673077ms)
✔ bus start runs until bus stop; status reports it while it runs (599.034968ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.385064ms)
✔ zoned uses the IANA zone across DST (29.32015ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (32.093104ms)
✔ two blocking decisions get two DMs with different nonces (24.339487ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.380926ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (21.641485ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (24.412687ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (34.384339ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (21.950533ms)
✔ 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 (147.176623ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (67.85557ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (17.354393ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.705565ms)
✔ an inbox read failure is logged and the next poll retries (1.292864ms)
✔ no Discord id reaches the journal or the log (13.724635ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.43761ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.635875ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.877784ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.988631ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.916525ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (3.689456ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.63676ms)
✖ 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.002906ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.619733ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.766019ms)
✔ digest content stays within Discord's 2000 characters (0.414363ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.963111ms)
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 (400.540416ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (78.133318ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2261.886579ms)
✔ busExit and refuseInsideAgent (0.404975ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.38687ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.650397ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.579376ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.436915ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.232529ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.30509ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.549517ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.467827ms)
✔ authorize: open channel, listed user (2.12047ms)
✔ authorize: wrong guild (0.221031ms)
✔ authorize: no guild (DM) (0.320478ms)
✔ authorize: unlisted channel (0.245649ms)
✔ authorize: unknown channel, no info (0.246528ms)
✔ authorize: thread of listed parent (0.236648ms)
✔ authorize: thread of unlisted parent (0.172043ms)
✔ authorize: text channel that is not a thread and not listed (0.13838ms)
✔ authorize: unlisted user (0.222069ms)
✔ authorize: no author (0.295863ms)
✔ authorize: bot author (listed id, bot flag) (0.119327ms)
✔ authorize: system author (0.118228ms)
✔ authorize: the bot itself (0.081957ms)
✔ authorize: webhook (0.106363ms)
✔ authorize: mention channel without mention (0.141546ms)
✔ authorize: mention channel with bot mention (0.156272ms)
✔ authorize: mention channel with @everyone only (0.197264ms)
✔ authorize: mention channel mentioning someone else (0.090356ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.084168ms)
✔ authorize: private thread under mention channel, mentioned (0.088968ms)
✔ authorize: private thread under mention channel, not mentioned (0.067423ms)
✔ authorize: thread in another guild per channel info (0.081961ms)
✔ authorize: not an object (0.060095ms)
✔ authorize: no id (0.068223ms)
✔ authorize: oversize content is accepted and flagged (0.076584ms)
✔ authorize: exactly the limit is not oversize (0.073325ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.487053ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.14135ms)
✔ binding: a complete binding validates and is frozen (2.784197ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.381278ms)
✔ binding: empty allowlists refuse (0.314119ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.347398ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.702708ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.440487ms)
✔ binding: file must be 0600, regular, not a symlink (1.873649ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.674288ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (117.953952ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.82238ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (435.829321ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (194.715216ms)
✔ cli: run refuses when STOP is present, before any network use (139.446086ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.586151ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.722238ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (21.834744ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (22.671535ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.573802ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.415378ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.513706ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.998649ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.717283ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.221016ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.201271ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.315174ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.254552ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.185363ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.598939ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.368063ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.122311ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.679614ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.043552ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.788093ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.640348ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (1.831719ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.489307ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.03311ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.200112ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (3.011081ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.926233ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.053884ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.282724ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.545135ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.218583ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.785159ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (3.403628ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.477039ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (62.576182ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (48.928465ms)
✔ engine: one prompt, one turn, text and usage come back (56.275209ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (369.007156ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (230.620366ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.775658ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (232.86874ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (131.936448ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.379176ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.893284ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.354551ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.517091ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (427.462122ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.835689ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.920356ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.795155ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (2.042813ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.555113ms)
✔ gateway: op 9 resumable resumes (0.604983ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.887955ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.634997ms)
✔ gateway: close() is final and unparseable frames are ignored (0.506437ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (69.440858ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (59.404187ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (144.905989ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.469977ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (125.120538ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (92.662615ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (87.436344ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (207.118827ms)
✔ git: push pushes the named branch only and reports up to date (73.66507ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (98.868478ms)
✔ git: the credential helper answers get over https from a private file and nothing else (206.555736ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (760.903329ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.182619ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (96.044054ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.281212ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.740846ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (60.70269ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (380.809018ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.433994ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.947937ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.934064ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (35.156902ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (144.969601ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (90.638653ms)
✔ notices: a kind is recorded per UTC day and found again (0.49102ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.69234ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.086177ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.85341ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (63.7668ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (62.610912ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (52.244823ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (104.328488ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (685.874805ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.660008ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.537445ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.638554ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.831552ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.896568ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.811109ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.034323ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.601385ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.667596ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (33.318283ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (19.649509ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.774997ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.003522ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.814474ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.342017ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1011.948849ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.445227ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.070159ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.241115ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.193684ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.278055ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.692239ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.624775ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (6.445555ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.19918ms)
✔ tools: listing and search caps hold (11.090696ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.152287ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (7.461367ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.556714ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (2.557182ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (7.360418ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (4.496653ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.333522ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.808146ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.519399ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1030.014111ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.207652ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.244652ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (3.013093ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.458801ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3158.269529
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:384:1
✖ 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.002906ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: ENOENT: no such file or directory, mkdir '/mnt/storage/scratch/tmp/mosaic-cli-td6Xqg/notify/demo'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:389:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: ENOENT: no such file or directory, mkdir '/mnt/storage/scratch/tmp/mosaic-cli-td6Xqg/notify/demo'
at mkdirSync (node:fs:1734:26)
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:162:5)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:389:23
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:389:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (25.13219ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (40.329677ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (17.847648ms)
✔ decide prints a declining choice as declining (16.236026ms)
✔ an unknown outcome is reported once and never resent (14.614685ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.96882ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (20.265592ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (17.847024ms)
✔ every human command refuses inside an agent run before it touches the bus (19.0666ms)
✔ usage errors exit 4; no business and no host is a usage error (23.771595ms)
✔ agents and tasks print through the broker (22.439025ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (5.615165ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (79.614922ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (50.629582ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (59.956202ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (62.369032ms)
✔ a business without tracker.baseUrl gets no trackers entry (72.803213ms)
✔ an unknown business and a broken system config refuse with exit 3 (91.434873ms)
✔ empty views say so (1.119896ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.39368ms)
✔ tasks print the tracker fields the snapshot carries (0.230495ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (971.408631ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (156.703759ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (82.464757ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (132.155646ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (122.572416ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (93.474394ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (124.413606ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (73.070299ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (136.766699ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.679527ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.920199ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (199.4411ms)
✔ bus start refuses with exit 3 without a notifier config (85.823805ms)
✔ bus start runs until bus stop; status reports it while it runs (598.242788ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.796886ms)
✔ zoned uses the IANA zone across DST (31.356487ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (25.728704ms)
✔ two blocking decisions get two DMs with different nonces (21.23633ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.35172ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (27.227779ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (23.50348ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.462406ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.183037ms)
✔ 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 (164.8161ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (65.126378ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.207487ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.553097ms)
✔ an inbox read failure is logged and the next poll retries (0.803056ms)
✔ no Discord id reaches the journal or the log (9.552876ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (4.143616ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.828747ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.722799ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.618835ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.666091ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.135547ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.383772ms)
✖ 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.923198ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.497729ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.780238ms)
✔ digest content stays within Discord's 2000 characters (0.332734ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.842936ms)
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 (378.485199ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (60.534995ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2279.507696ms)
✔ busExit and refuseInsideAgent (0.401143ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.711334ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.445569ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.548543ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.553557ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (17.54013ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.876847ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.350282ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.337891ms)
✔ authorize: open channel, listed user (1.89692ms)
✔ authorize: wrong guild (0.196017ms)
✔ authorize: no guild (DM) (0.159648ms)
✔ authorize: unlisted channel (0.211528ms)
✔ authorize: unknown channel, no info (0.17437ms)
✔ authorize: thread of listed parent (0.186026ms)
✔ authorize: thread of unlisted parent (0.149734ms)
✔ authorize: text channel that is not a thread and not listed (0.146165ms)
✔ authorize: unlisted user (0.190422ms)
✔ authorize: no author (0.287307ms)
✔ authorize: bot author (listed id, bot flag) (0.146782ms)
✔ authorize: system author (0.124802ms)
✔ authorize: the bot itself (0.1379ms)
✔ authorize: webhook (0.10391ms)
✔ authorize: mention channel without mention (0.129963ms)
✔ authorize: mention channel with bot mention (0.132393ms)
✔ authorize: mention channel with @everyone only (0.11567ms)
✔ authorize: mention channel mentioning someone else (0.079587ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.110011ms)
✔ authorize: private thread under mention channel, mentioned (0.105934ms)
✔ authorize: private thread under mention channel, not mentioned (0.08689ms)
✔ authorize: thread in another guild per channel info (0.100341ms)
✔ authorize: not an object (0.069366ms)
✔ authorize: no id (0.059022ms)
✔ authorize: oversize content is accepted and flagged (0.07687ms)
✔ authorize: exactly the limit is not oversize (0.065262ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.475774ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.183429ms)
✔ binding: a complete binding validates and is frozen (2.480725ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.325315ms)
✔ binding: empty allowlists refuse (0.424082ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.312887ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.647852ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.591747ms)
✔ binding: file must be 0600, regular, not a symlink (1.48463ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.603943ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (109.395682ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (4.189775ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (423.629847ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (253.14589ms)
✔ cli: run refuses when STOP is present, before any network use (159.519301ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.672429ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.221942ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.249242ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.409761ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.784586ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.624831ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.46645ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.239191ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.115343ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (2.388532ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.369891ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.31863ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (43.058028ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (34.555152ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.050443ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.059826ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.062894ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.293209ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.111608ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.96512ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (5.176493ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (8.066122ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.899833ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.879016ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.270749ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (4.846363ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.827351ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.893573ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.312639ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.522057ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.193669ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.6605ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (3.084023ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.458312ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (67.390331ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (54.367852ms)
✔ engine: one prompt, one turn, text and usage come back (53.716909ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (372.16094ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (251.624099ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (105.442123ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.781561ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (130.480676ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.285834ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.337496ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.266264ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.495014ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.663698ms)
✔ engine: a malformed JSONL line fails the turn, not the process (27.427227ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.004989ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (4.259634ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (3.447212ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.53773ms)
✔ gateway: op 9 resumable resumes (0.345384ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.966945ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.566464ms)
✔ gateway: close() is final and unparseable frames are ignored (0.501479ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (72.158667ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (65.988141ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (170.563911ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.320126ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (92.563422ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (98.500161ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (128.577265ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (269.647215ms)
✔ git: push pushes the named branch only and reports up to date (69.971408ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (91.267221ms)
✔ git: the credential helper answers get over https from a private file and nothing else (201.812402ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (751.369703ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (3.981124ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (88.744096ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.157419ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.632646ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (76.48799ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (394.958079ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.657331ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (32.218768ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.631053ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (54.600895ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (174.674862ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (89.399144ms)
✔ notices: a kind is recorded per UTC day and found again (0.538714ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.288162ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.219281ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.803445ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (52.718397ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (61.85884ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (50.757952ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (81.600707ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (763.284875ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.603613ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (3.566635ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.703504ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.720263ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.808599ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.694852ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.735946ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.587449ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (1.949424ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.918762ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (11.929938ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.588893ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (6.721566ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.697645ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.018695ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1020.639488ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.54979ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.54055ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (2.957801ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.228015ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.324914ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.294113ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.812717ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (8.630648ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (6.776269ms)
✔ tools: listing and search caps hold (21.511378ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.122692ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.242925ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.485973ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.648253ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.444422ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.11594ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.084456ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.536126ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.463118ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1029.148129ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.289141ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.236207ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.675576ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.296962ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3164.258704
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:384:1
✖ 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.923198ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: ENOTDIR: not a directory, mkdir '/mnt/storage/scratch/tmp/mosaic-cli-ep4mvm/plain/notify/demo'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:392:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: ENOTDIR: not a directory, mkdir '/mnt/storage/scratch/tmp/mosaic-cli-ep4mvm/plain/notify/demo'
at mkdirSync (node:fs:1734:26)
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:162:5)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:392:23
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:392:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (28.859169ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (43.522891ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (26.19858ms)
✔ decide prints a declining choice as declining (18.829143ms)
✔ an unknown outcome is reported once and never resent (15.751541ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (16.438474ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (17.051428ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (19.773923ms)
✔ every human command refuses inside an agent run before it touches the bus (17.28548ms)
✔ usage errors exit 4; no business and no host is a usage error (24.900065ms)
✔ agents and tasks print through the broker (18.520332ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.497874ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (76.618004ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (72.088781ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (64.855255ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (61.500092ms)
✔ a business without tracker.baseUrl gets no trackers entry (61.348789ms)
✔ an unknown business and a broken system config refuse with exit 3 (86.301376ms)
✔ empty views say so (1.090566ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.393387ms)
✔ tasks print the tracker fields the snapshot carries (0.245999ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (985.42783ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (148.671929ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (80.456483ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (129.760165ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (125.886644ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (79.358909ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (123.89411ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (88.464277ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (135.851968ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (28.571507ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.046293ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.624246ms)
✔ bus start refuses with exit 3 without a notifier config (90.134268ms)
✔ bus start runs until bus stop; status reports it while it runs (596.260356ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.19919ms)
✔ zoned uses the IANA zone across DST (26.692816ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (31.916057ms)
✔ two blocking decisions get two DMs with different nonces (32.543329ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.593689ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.621592ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (41.181728ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (28.69903ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (16.84485ms)
✔ 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 (151.570423ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (61.177872ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (16.092738ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.267999ms)
✔ an inbox read failure is logged and the next poll retries (0.772851ms)
✔ no Discord id reaches the journal or the log (9.717701ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.12873ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.642249ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.748411ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.4724ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.553383ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.824286ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.325175ms)
✖ 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.878089ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.377773ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.454861ms)
✔ digest content stays within Discord's 2000 characters (0.258973ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.361261ms)
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 (392.782821ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (53.652805ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2305.145021ms)
✔ busExit and refuseInsideAgent (0.408181ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (4.267462ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (3.452639ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.845017ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.701646ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.334613ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.68051ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (5.912514ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.983616ms)
✔ authorize: open channel, listed user (1.817784ms)
✔ authorize: wrong guild (0.211759ms)
✔ authorize: no guild (DM) (0.18151ms)
✔ authorize: unlisted channel (0.186039ms)
✔ authorize: unknown channel, no info (0.168586ms)
✔ authorize: thread of listed parent (0.220159ms)
✔ authorize: thread of unlisted parent (0.158735ms)
✔ authorize: text channel that is not a thread and not listed (0.145594ms)
✔ authorize: unlisted user (0.212654ms)
✔ authorize: no author (0.289283ms)
✔ authorize: bot author (listed id, bot flag) (0.170478ms)
✔ authorize: system author (0.152365ms)
✔ authorize: the bot itself (0.10816ms)
✔ authorize: webhook (0.100938ms)
✔ authorize: mention channel without mention (0.124185ms)
✔ authorize: mention channel with bot mention (0.163965ms)
✔ authorize: mention channel with @everyone only (0.112685ms)
✔ authorize: mention channel mentioning someone else (0.092501ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.097279ms)
✔ authorize: private thread under mention channel, mentioned (0.12121ms)
✔ authorize: private thread under mention channel, not mentioned (0.084618ms)
✔ authorize: thread in another guild per channel info (0.100982ms)
✔ authorize: not an object (0.071546ms)
✔ authorize: no id (0.06563ms)
✔ authorize: oversize content is accepted and flagged (0.072863ms)
✔ authorize: exactly the limit is not oversize (0.071345ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.480647ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.158273ms)
✔ binding: a complete binding validates and is frozen (2.722269ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.311563ms)
✔ binding: empty allowlists refuse (0.389812ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (2.987714ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (3.38944ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.789481ms)
✔ binding: file must be 0600, regular, not a symlink (1.776621ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.683713ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (133.074131ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.216267ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (390.462053ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (211.112392ms)
✔ cli: run refuses when STOP is present, before any network use (136.493498ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.815175ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.223446ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (17.897595ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.709019ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.937378ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.261717ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.43824ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.435281ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.188662ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.190739ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (4.526686ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.355518ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (49.257438ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.884449ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.372369ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.206647ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.167803ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.271301ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.39736ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (2.014298ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.52139ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (4.056645ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.894751ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.786506ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.124475ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.299552ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.898687ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.753624ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.155638ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.329311ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.329615ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.629937ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.60156ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.412031ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (59.470308ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (65.201222ms)
✔ engine: one prompt, one turn, text and usage come back (53.296921ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (362.742469ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (235.212599ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (105.379544ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (232.560913ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.29101ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.676517ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.267154ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.324488ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.511341ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.115054ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.439637ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.434863ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.598401ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.698896ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.558802ms)
✔ gateway: op 9 resumable resumes (0.41104ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (1.113347ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.501856ms)
✔ gateway: close() is final and unparseable frames are ignored (0.539476ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (84.848713ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (66.044753ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (134.425936ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.484123ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (97.198044ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (87.27449ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (98.883283ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (210.094729ms)
✔ git: push pushes the named branch only and reports up to date (69.268475ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (90.899035ms)
✔ git: the credential helper answers get over https from a private file and nothing else (211.208093ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (765.663195ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.21821ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (93.38947ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.324053ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.404865ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (65.740808ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (348.345567ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.572613ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (29.276208ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.82824ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (48.859496ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (143.702874ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (83.318894ms)
✔ notices: a kind is recorded per UTC day and found again (0.437678ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.53815ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.604759ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.733651ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.403721ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (54.868213ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (43.315297ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (91.590305ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (670.090674ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.619633ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (7.246855ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.70481ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.714464ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.799529ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.798929ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.065246ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.741474ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.346906ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (30.265903ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.294229ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.769052ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.984127ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.687406ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.248138ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1014.089592ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.451974ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.981803ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.253853ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.194411ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.252605ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.872341ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.271195ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (6.154442ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.774478ms)
✔ tools: listing and search caps hold (11.623179ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.897446ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.505285ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.539266ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.752099ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.426594ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.109165ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.784497ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.391923ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.726674ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1028.721511ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.249065ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.234037ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.579097ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.489611ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3183.915811
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:384:1
✖ 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.878089ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: EISDIR: illegal operation on a directory, open '/mnt/storage/scratch/tmp/mosaic-cli-OmjGzR/notify/acme/sent.jsonl'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:395:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: EISDIR: illegal operation on a directory, open '/mnt/storage/scratch/tmp/mosaic-cli-OmjGzR/notify/acme/sent.jsonl'
at openSync (node:fs:779:18)
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:182:10)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:395:23
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:395:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.145901ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (47.775731ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (23.290099ms)
✔ decide prints a declining choice as declining (22.078854ms)
✔ an unknown outcome is reported once and never resent (15.800453ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.512369ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (19.855539ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (17.272377ms)
✔ every human command refuses inside an agent run before it touches the bus (25.308467ms)
✔ usage errors exit 4; no business and no host is a usage error (21.103783ms)
✔ agents and tasks print through the broker (19.011515ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (5.051234ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (86.869045ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (63.977185ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (53.610588ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (60.499515ms)
✔ a business without tracker.baseUrl gets no trackers entry (64.885946ms)
✔ an unknown business and a broken system config refuse with exit 3 (95.45487ms)
✔ empty views say so (0.998112ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.25455ms)
✔ tasks print the tracker fields the snapshot carries (0.228409ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (984.058106ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (146.819118ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (76.727453ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (124.990947ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (129.654431ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.785161ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (124.690274ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.685271ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (139.433887ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.684881ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.807303ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (200.875958ms)
✔ bus start refuses with exit 3 without a notifier config (88.51793ms)
✔ bus start runs until bus stop; status reports it while it runs (602.262755ms)
✔ bus-service.sh renders the unit and installs it into a given directory (25.852417ms)
✔ zoned uses the IANA zone across DST (27.817773ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (35.228068ms)
✔ two blocking decisions get two DMs with different nonces (21.845993ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.392654ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (21.091273ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (25.404083ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (29.036092ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.295857ms)
✔ 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 (149.055471ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (68.583829ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (18.844683ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (11.114662ms)
✔ an inbox read failure is logged and the next poll retries (0.939492ms)
✔ no Discord id reaches the journal or the log (11.316488ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.438592ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.303883ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.783781ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.594691ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.711371ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.226472ms)
✖ the journal: a symlinked directory refuses and says it is a link (0.924893ms)
✔ 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.60274ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.415488ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.559483ms)
✔ digest content stays within Discord's 2000 characters (0.314308ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.553163ms)
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 (382.349984ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (65.77689ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2275.536161ms)
✔ busExit and refuseInsideAgent (0.396752ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.865358ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.730839ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.645587ms)
✔ approvals: a button approves only on its own request message with the matching custom id (1.144832ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (16.286897ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.785029ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (9.217213ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (3.035382ms)
✔ authorize: open channel, listed user (1.743476ms)
✔ authorize: wrong guild (0.184716ms)
✔ authorize: no guild (DM) (0.296654ms)
✔ authorize: unlisted channel (0.173389ms)
✔ authorize: unknown channel, no info (0.268458ms)
✔ authorize: thread of listed parent (0.193059ms)
✔ authorize: thread of unlisted parent (0.236881ms)
✔ authorize: text channel that is not a thread and not listed (0.125466ms)
✔ authorize: unlisted user (0.348601ms)
✔ authorize: no author (0.353029ms)
✔ authorize: bot author (listed id, bot flag) (0.482526ms)
✔ authorize: system author (0.12468ms)
✔ authorize: the bot itself (0.089956ms)
✔ authorize: webhook (0.089729ms)
✔ authorize: mention channel without mention (0.26233ms)
✔ authorize: mention channel with bot mention (0.173457ms)
✔ authorize: mention channel with @everyone only (0.090435ms)
✔ authorize: mention channel mentioning someone else (0.099016ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.072492ms)
✔ authorize: private thread under mention channel, mentioned (0.081666ms)
✔ authorize: private thread under mention channel, not mentioned (0.061721ms)
✔ authorize: thread in another guild per channel info (0.064458ms)
✔ authorize: not an object (0.058966ms)
✔ authorize: no id (0.060906ms)
✔ authorize: oversize content is accepted and flagged (0.06914ms)
✔ authorize: exactly the limit is not oversize (0.065672ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.444027ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.151302ms)
✔ binding: a complete binding validates and is frozen (2.523579ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.263667ms)
✔ binding: empty allowlists refuse (0.401048ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.289347ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.532389ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.478841ms)
✔ binding: file must be 0600, regular, not a symlink (1.82097ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.977497ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (118.296279ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.397965ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (411.521282ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (194.550815ms)
✔ cli: run refuses when STOP is present, before any network use (137.779632ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.974567ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.331492ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.704601ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (19.381665ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.854148ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.448128ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.676395ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.10638ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (32.353066ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.258277ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.485906ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.295699ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.441789ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.751289ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.085765ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (3.86994ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (2.702837ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.307418ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.953891ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.08887ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (6.630868ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.42889ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.417568ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (5.335304ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.443952ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (3.198098ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.817768ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.091539ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.905905ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.501795ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.130302ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.657428ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.38731ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.426603ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (78.967225ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (52.756224ms)
✔ engine: one prompt, one turn, text and usage come back (53.36595ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (369.503573ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (232.013615ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.532393ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.448361ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.909456ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (211.311518ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.608651ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.357412ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.50683ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.105228ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.468989ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.380584ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.994664ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (3.166413ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (1.288407ms)
✔ gateway: op 9 resumable resumes (0.433182ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.871737ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.533253ms)
✔ gateway: close() is final and unparseable frames are ignored (0.389817ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (74.178775ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (61.149552ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (139.63557ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.475845ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (104.399929ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (90.164396ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (85.522973ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (196.97209ms)
✔ git: push pushes the named branch only and reports up to date (70.762086ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (86.674254ms)
✔ git: the credential helper answers get over https from a private file and nothing else (206.393074ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (731.229421ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (6.477414ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (89.953235ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.449324ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.198128ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (62.418246ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (337.937949ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.458647ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (22.278618ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.969196ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (37.649373ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (144.799697ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (87.006079ms)
✔ notices: a kind is recorded per UTC day and found again (0.484099ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.313965ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.115607ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.821127ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.596185ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (59.831781ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (47.880019ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (84.500653ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (647.531044ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.486496ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.502554ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.670847ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.795543ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.918446ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.375599ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.438184ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (2.351938ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.312775ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.677544ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.871377ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.053667ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.467615ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.691971ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.509863ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1013.970826ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.440192ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.486217ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.23785ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.20178ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.824088ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (3.713981ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (5.211396ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.380258ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.914022ms)
✔ tools: listing and search caps hold (11.492148ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.929013ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.212559ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.362676ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.405731ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.365431ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.934561ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.869419ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.863278ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (3.948662ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1032.943649ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.171229ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.232197ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.681056ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.873492ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3143.804027
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:373:1
✖ the journal: a symlinked directory refuses and says it is a link (0.924893ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:380:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,273 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (28.737033ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (35.163479ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (21.102508ms)
✔ decide prints a declining choice as declining (25.358311ms)
✔ an unknown outcome is reported once and never resent (19.13216ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (16.11116ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.68501ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (16.604187ms)
✔ every human command refuses inside an agent run before it touches the bus (17.389564ms)
✔ usage errors exit 4; no business and no host is a usage error (18.438398ms)
✔ agents and tasks print through the broker (17.364042ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.823383ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (79.301366ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.327636ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (56.690964ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (58.286999ms)
✔ a business without tracker.baseUrl gets no trackers entry (74.637089ms)
✔ an unknown business and a broken system config refuse with exit 3 (85.803721ms)
✔ empty views say so (1.012118ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.271847ms)
✔ tasks print the tracker fields the snapshot carries (0.278744ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (966.100787ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (140.450345ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (78.156839ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (133.865834ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (123.350771ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (77.326819ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (122.516647ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (86.335457ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (136.683399ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (25.080292ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.356572ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (223.727265ms)
✔ bus start refuses with exit 3 without a notifier config (96.900998ms)
✔ bus start runs until bus stop; status reports it while it runs (604.558178ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.650399ms)
✔ zoned uses the IANA zone across DST (30.182663ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (24.704189ms)
✔ two blocking decisions get two DMs with different nonces (22.481546ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.320562ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (23.793991ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (31.135777ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (30.905443ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.455431ms)
✔ 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 (155.226466ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (58.182106ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.722414ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.65291ms)
✔ an inbox read failure is logged and the next poll retries (0.713996ms)
✔ no Discord id reaches the journal or the log (9.632608ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (1.994709ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.566077ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.628868ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.494324ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.632544ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.218066ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.367964ms)
✔ 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.513813ms)
✖ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.868388ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.499484ms)
✔ digest content stays within Discord's 2000 characters (0.271897ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.946353ms)
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 (363.490563ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (54.836406ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2251.792796ms)
✔ busExit and refuseInsideAgent (0.415315ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (2.997865ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.333382ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.473973ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.431141ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (19.162547ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.879811ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (10.405ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.102995ms)
✔ authorize: open channel, listed user (1.735923ms)
✔ authorize: wrong guild (0.180339ms)
✔ authorize: no guild (DM) (0.286307ms)
✔ authorize: unlisted channel (0.20056ms)
✔ authorize: unknown channel, no info (0.170004ms)
✔ authorize: thread of listed parent (0.18518ms)
✔ authorize: thread of unlisted parent (0.146331ms)
✔ authorize: text channel that is not a thread and not listed (0.151178ms)
✔ authorize: unlisted user (0.282805ms)
✔ authorize: no author (0.27851ms)
✔ authorize: bot author (listed id, bot flag) (0.178332ms)
✔ authorize: system author (0.462125ms)
✔ authorize: the bot itself (0.09571ms)
✔ authorize: webhook (0.094039ms)
✔ authorize: mention channel without mention (0.205392ms)
✔ authorize: mention channel with bot mention (0.13218ms)
✔ authorize: mention channel with @everyone only (0.220732ms)
✔ authorize: mention channel mentioning someone else (0.072694ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.109539ms)
✔ authorize: private thread under mention channel, mentioned (0.0936ms)
✔ authorize: private thread under mention channel, not mentioned (0.098945ms)
✔ authorize: thread in another guild per channel info (0.094707ms)
✔ authorize: not an object (0.078398ms)
✔ authorize: no id (0.082876ms)
✔ authorize: oversize content is accepted and flagged (0.07022ms)
✔ authorize: exactly the limit is not oversize (0.068912ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.460012ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.136121ms)
✔ binding: a complete binding validates and is frozen (2.674386ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.306358ms)
✔ binding: empty allowlists refuse (0.360495ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.236208ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.721003ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.462088ms)
✔ binding: file must be 0600, regular, not a symlink (1.920643ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.372535ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (105.709582ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.49337ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (400.329376ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (191.864176ms)
✔ cli: run refuses when STOP is present, before any network use (124.815718ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.901816ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.160395ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.079564ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (17.176383ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.959322ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.154588ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.416762ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.548332ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (35.099227ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.910759ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (0.978617ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.817652ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.228979ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (35.631341ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.862583ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (6.208461ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.056737ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (9.961835ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (5.850278ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.221581ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (2.848345ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.72145ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.967844ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.705275ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (6.680507ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.463619ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.758355ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.726393ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.126448ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.36912ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.085982ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.635253ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.586546ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.480372ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (56.372209ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (55.646961ms)
✔ engine: one prompt, one turn, text and usage come back (60.189032ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (360.407805ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (229.179405ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.027644ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (229.755165ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (125.50392ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.795487ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (615.116051ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.339152ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.504355ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (430.36146ms)
✔ engine: a malformed JSONL line fails the turn, not the process (31.30779ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (49.029622ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (1.998981ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.888521ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.513941ms)
✔ gateway: op 9 resumable resumes (0.353007ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.882529ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.49968ms)
✔ gateway: close() is final and unparseable frames are ignored (0.350207ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (76.166016ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (71.065158ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (159.994097ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (1.188851ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (93.420476ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (85.835549ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (82.56582ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (196.144674ms)
✔ git: push pushes the named branch only and reports up to date (64.371408ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (85.736535ms)
✔ git: the credential helper answers get over https from a private file and nothing else (203.13621ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (759.727611ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.345198ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (79.066423ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (0.927796ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (3.741239ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (54.643126ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (342.081483ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.393045ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.478182ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.064208ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (40.02632ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (123.791284ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (83.04366ms)
✔ notices: a kind is recorded per UTC day and found again (0.48889ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.290246ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.762358ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.595804ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (57.614945ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (59.971798ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (50.345009ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (91.724962ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (624.974757ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.103167ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.356734ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.632049ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.725331ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.816187ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.5774ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.826071ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (2.267287ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.038848ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (32.762066ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (14.048738ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (11.981418ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.886959ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.669385ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.270776ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1016.355023ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.420149ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.038896ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.430564ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.207487ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.632782ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.666314ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.070401ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.73592ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.959728ms)
✔ tools: listing and search caps hold (12.331689ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.146949ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.950845ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.209795ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.404605ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (8.08911ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.140938ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (2.597675ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.836394ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.278616ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1035.670196ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.288103ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.55435ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.729683ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.248629ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3181.332561
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:398:1
✖ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.868388ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:406:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,282 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (29.794649ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (38.94218ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (36.490099ms)
✔ decide prints a declining choice as declining (38.340314ms)
✔ an unknown outcome is reported once and never resent (24.278352ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (16.469313ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (21.948304ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (21.275067ms)
✔ every human command refuses inside an agent run before it touches the bus (16.125688ms)
✔ usage errors exit 4; no business and no host is a usage error (23.9681ms)
✔ agents and tasks print through the broker (15.874847ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.526583ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (95.999475ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (76.139065ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (68.224936ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (56.183516ms)
✔ a business without tracker.baseUrl gets no trackers entry (59.35911ms)
✔ an unknown business and a broken system config refuse with exit 3 (95.446003ms)
✔ empty views say so (1.218516ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (2.365312ms)
✔ tasks print the tracker fields the snapshot carries (0.263557ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1021.348478ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (156.932518ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (83.638475ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (139.574988ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (139.856077ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (72.008017ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (131.170086ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (71.932431ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (143.292727ms)
✖ watchChildren reports a child that died before it was called, and one that dies later (26.659105ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.753341ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (217.069241ms)
✔ bus start refuses with exit 3 without a notifier config (117.770349ms)
✔ bus start runs until bus stop; status reports it while it runs (627.610933ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.963314ms)
✔ zoned uses the IANA zone across DST (27.268366ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (40.291171ms)
✔ two blocking decisions get two DMs with different nonces (23.386255ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.313306ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (27.950556ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (60.035804ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (27.619225ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.393879ms)
✔ 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 (155.418551ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (69.101062ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (21.171124ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.111513ms)
✔ an inbox read failure is logged and the next poll retries (1.065946ms)
✔ no Discord id reaches the journal or the log (11.034042ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.385735ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.873808ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.398396ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.770734ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.791724ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (3.349674ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.658124ms)
✔ 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.799959ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.60515ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.752058ms)
✔ digest content stays within Discord's 2000 characters (0.474371ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.732754ms)
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 (406.738827ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (68.919135ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2272.81185ms)
✔ busExit and refuseInsideAgent (0.396368ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.115426ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.864066ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.639613ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.476948ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (23.164584ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.803285ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.567729ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (3.874416ms)
✔ authorize: open channel, listed user (2.374951ms)
✔ authorize: wrong guild (0.203186ms)
✔ authorize: no guild (DM) (0.314521ms)
✔ authorize: unlisted channel (0.184651ms)
✔ authorize: unknown channel, no info (0.264493ms)
✔ authorize: thread of listed parent (0.226501ms)
✔ authorize: thread of unlisted parent (0.172718ms)
✔ authorize: text channel that is not a thread and not listed (0.189471ms)
✔ authorize: unlisted user (1.168464ms)
✔ authorize: no author (0.360857ms)
✔ authorize: bot author (listed id, bot flag) (0.149738ms)
✔ authorize: system author (0.119963ms)
✔ authorize: the bot itself (0.086895ms)
✔ authorize: webhook (0.274611ms)
✔ authorize: mention channel without mention (0.730675ms)
✔ authorize: mention channel with bot mention (0.130524ms)
✔ authorize: mention channel with @everyone only (0.083595ms)
✔ authorize: mention channel mentioning someone else (0.09759ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.075999ms)
✔ authorize: private thread under mention channel, mentioned (0.082108ms)
✔ authorize: private thread under mention channel, not mentioned (0.091275ms)
✔ authorize: thread in another guild per channel info (0.077608ms)
✔ authorize: not an object (0.063892ms)
✔ authorize: no id (0.064984ms)
✔ authorize: oversize content is accepted and flagged (0.06103ms)
✔ authorize: exactly the limit is not oversize (0.062682ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.471043ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.151618ms)
✔ binding: a complete binding validates and is frozen (2.41465ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.237917ms)
✔ binding: empty allowlists refuse (0.631065ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.313639ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.781488ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.786813ms)
✔ binding: file must be 0600, regular, not a symlink (1.559581ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.901167ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (112.038118ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (4.594124ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (459.944523ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (200.449425ms)
✔ cli: run refuses when STOP is present, before any network use (131.496934ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.731461ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.230436ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (16.861301ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (31.031611ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.579151ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.643543ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.532429ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (4.598246ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.438061ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (4.353558ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.936551ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.123755ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.673073ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (31.606126ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (6.177237ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (5.319998ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.617264ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.38716ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.348666ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.959916ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (6.743145ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.751288ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.457921ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (2.906819ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.713777ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.5633ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.867817ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.8518ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.164419ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.43115ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.480447ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.901129ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.348155ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.43701ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (67.985039ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (57.926809ms)
✔ engine: one prompt, one turn, text and usage come back (72.056593ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (365.551435ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.213673ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.766667ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (230.964112ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (128.137334ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.846242ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.452263ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.329494ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.514545ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.338658ms)
✔ engine: a malformed JSONL line fails the turn, not the process (23.521958ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (52.248775ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.558948ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.757563ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.491835ms)
✔ gateway: op 9 resumable resumes (0.384894ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (1.010785ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.50756ms)
✔ gateway: close() is final and unparseable frames are ignored (0.485292ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (103.893504ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (68.936312ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (118.493364ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.709579ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (107.394682ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (86.981856ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (91.299865ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (196.742625ms)
✔ git: push pushes the named branch only and reports up to date (69.575682ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (82.575356ms)
✔ git: the credential helper answers get over https from a private file and nothing else (221.530799ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (801.329172ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (5.008682ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (105.008354ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.157561ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.293655ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (65.603737ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (371.422885ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.379133ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (23.900067ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.239444ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (39.851252ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (137.413265ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (85.013967ms)
✔ notices: a kind is recorded per UTC day and found again (0.417344ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.485599ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (3.033017ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.850845ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (51.666066ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (54.668767ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (49.548825ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (109.641255ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (676.379003ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.635557ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.688728ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.873323ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.679665ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.844333ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.973939ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.542815ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.83329ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.200911ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (27.392124ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.169765ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (7.586821ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.356442ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.688455ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.149597ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1014.059454ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.438188ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.290361ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.198511ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.205713ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.264933ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.490622ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (5.283832ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.407578ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.549193ms)
✔ tools: listing and search caps hold (13.976203ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.912012ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.373079ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.473472ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.267278ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.91166ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (4.837165ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.993099ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.479683ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.898109ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1030.53154ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.812264ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.355601ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (3.014188ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (3.093352ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3330.138768
✖ failing tests:
test at packages/cli/tests/host.test.mjs:314:1
✖ watchChildren reports a child that died before it was called, and one that dies later (26.659105ms)
AssertionError [ERR_ASSERTION]: a death by signal before the watch is not lost either
+ actual - expected
+ []
- [
- [
- 'broker',
- null,
- 'SIGKILL'
- ]
- ]
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:323:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [],
expected: [ [ 'broker', null, 'SIGKILL' ] ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,290 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (30.266926ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (45.66417ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.856626ms)
✔ decide prints a declining choice as declining (20.024767ms)
✔ an unknown outcome is reported once and never resent (14.773851ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (17.412784ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (16.826666ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (14.717837ms)
✔ every human command refuses inside an agent run before it touches the bus (13.214516ms)
✔ usage errors exit 4; no business and no host is a usage error (18.808117ms)
✔ agents and tasks print through the broker (21.378996ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (5.916491ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (68.898749ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (71.363969ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (57.75907ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (51.943641ms)
✔ a business without tracker.baseUrl gets no trackers entry (57.687842ms)
✔ an unknown business and a broken system config refuse with exit 3 (89.099702ms)
✔ empty views say so (1.125502ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.538518ms)
✔ tasks print the tracker fields the snapshot carries (0.2228ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (979.821029ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (152.727215ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (79.070305ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (127.687377ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (123.155382ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.116208ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (134.247025ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (71.896615ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (137.451352ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (22.554712ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.798101ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (198.402743ms)
✔ bus start refuses with exit 3 without a notifier config (85.719086ms)
✔ bus start runs until bus stop; status reports it while it runs (595.430698ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.652828ms)
✔ zoned uses the IANA zone across DST (24.726385ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (29.152423ms)
✔ two blocking decisions get two DMs with different nonces (25.104303ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.327336ms)
✖ a failed DM is journaled, backs off, and is retried until it lands (35.595976ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (29.550213ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (28.075564ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (18.657591ms)
✖ 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 (145.611266ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (60.533301ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (14.616093ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.927771ms)
✔ an inbox read failure is logged and the next poll retries (1.064229ms)
✔ no Discord id reaches the journal or the log (9.423783ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.450495ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.148722ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.323057ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.50755ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.563077ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.890067ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.412297ms)
✔ 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.471954ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.367338ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.43487ms)
✔ digest content stays within Discord's 2000 characters (0.282615ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.129836ms)
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 (379.12536ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (64.309331ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2261.223759ms)
✔ busExit and refuseInsideAgent (0.401724ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.625669ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.826441ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.662925ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.505367ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (16.068818ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.204007ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (6.869574ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.944775ms)
✔ authorize: open channel, listed user (1.754008ms)
✔ authorize: wrong guild (0.332026ms)
✔ authorize: no guild (DM) (0.193086ms)
✔ authorize: unlisted channel (0.178597ms)
✔ authorize: unknown channel, no info (0.173256ms)
✔ authorize: thread of listed parent (0.199811ms)
✔ authorize: thread of unlisted parent (0.189604ms)
✔ authorize: text channel that is not a thread and not listed (0.148632ms)
✔ authorize: unlisted user (0.19263ms)
✔ authorize: no author (0.331679ms)
✔ authorize: bot author (listed id, bot flag) (0.203138ms)
✔ authorize: system author (0.112449ms)
✔ authorize: the bot itself (0.107046ms)
✔ authorize: webhook (0.078295ms)
✔ authorize: mention channel without mention (0.114163ms)
✔ authorize: mention channel with bot mention (0.134884ms)
✔ authorize: mention channel with @everyone only (0.124096ms)
✔ authorize: mention channel mentioning someone else (0.11984ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.094402ms)
✔ authorize: private thread under mention channel, mentioned (0.072586ms)
✔ authorize: private thread under mention channel, not mentioned (0.2045ms)
✔ authorize: thread in another guild per channel info (0.061572ms)
✔ authorize: not an object (0.056638ms)
✔ authorize: no id (0.050415ms)
✔ authorize: oversize content is accepted and flagged (0.059112ms)
✔ authorize: exactly the limit is not oversize (0.053124ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.522411ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.143517ms)
✔ binding: a complete binding validates and is frozen (2.50615ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.406254ms)
✔ binding: empty allowlists refuse (0.393586ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.314708ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.867263ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.536018ms)
✔ binding: file must be 0600, regular, not a symlink (1.867824ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.349392ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (113.67623ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (1.98223ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (395.157322ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (201.578655ms)
✔ cli: run refuses when STOP is present, before any network use (140.794764ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.735771ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.09323ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (20.937731ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.751751ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (2.441554ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (2.113564ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.664805ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.320447ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (33.950871ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.1345ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.028243ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.016164ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (45.347222ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (35.309874ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.466538ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (3.882997ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.167248ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.589504ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (2.571267ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.934983ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (3.191145ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.527371ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (6.682676ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (4.58346ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (8.265489ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.591628ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.800393ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.901467ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.220002ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.452872ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.287682ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (3.287543ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.641894ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.471487ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (60.916789ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (59.448834ms)
✔ engine: one prompt, one turn, text and usage come back (48.312646ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (354.971161ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (233.138971ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (104.037246ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (228.741085ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (129.568859ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.499258ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.510265ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.569479ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.532706ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (424.943568ms)
✔ engine: a malformed JSONL line fails the turn, not the process (26.739074ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (46.351166ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (3.094678ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (2.13718ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.849978ms)
✔ gateway: op 9 resumable resumes (0.439768ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.915492ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.673117ms)
✔ gateway: close() is final and unparseable frames are ignored (0.393606ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (91.065486ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (72.446294ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (124.799928ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.432106ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (90.989316ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (91.183644ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (100.13924ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (212.267433ms)
✔ git: push pushes the named branch only and reports up to date (64.430551ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (79.208924ms)
✔ git: the credential helper answers get over https from a private file and nothing else (204.378212ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (750.14077ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.477235ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (97.71233ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.381396ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.158657ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (60.604439ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (342.045322ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.423641ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (23.41472ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.399657ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (50.239489ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (139.135309ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (86.025792ms)
✔ notices: a kind is recorded per UTC day and found again (0.415185ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (4.593522ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (1.867258ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.246599ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (55.201975ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (55.320297ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (39.592411ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (79.198989ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (669.553856ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.326724ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.511643ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.596354ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.712157ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.149145ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.92729ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (2.952258ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.914605ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.341751ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.552894ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.513095ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.346809ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.58156ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.904232ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.244152ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1015.260478ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.405047ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.013748ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.301499ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.213923ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.229473ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.08884ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.051327ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.231214ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.598406ms)
✔ tools: listing and search caps hold (11.303745ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.023481ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.959723ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.377975ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.383195ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.391934ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.697706ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.450399ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.705902ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.274213ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1025.356016ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.295926ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.211448ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.720934ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.211507ms)
ℹ tests 244
ℹ suites 0
ℹ pass 242
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3154.843627
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:90:1
✖ a failed DM is journaled, backs off, and is retried until it lands (35.595976ms)
AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:
assert.ok(s.logs.some((l) => /refused \(HTTP 403\); retry in 1800 s/.test(l)))
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:106:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
test at packages/cli/tests/notifier.test.mjs:189:1
✖ 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 (145.611266ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 4
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:198:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 4,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,277 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (28.161834ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (34.245716ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (20.035247ms)
✔ decide prints a declining choice as declining (19.891429ms)
✔ an unknown outcome is reported once and never resent (17.257652ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (20.475501ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (18.291156ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (18.810661ms)
✔ every human command refuses inside an agent run before it touches the bus (26.300885ms)
✔ usage errors exit 4; no business and no host is a usage error (21.769095ms)
✔ agents and tasks print through the broker (16.052438ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (4.070747ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (71.132011ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (56.117223ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (67.654898ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (58.652035ms)
✔ a business without tracker.baseUrl gets no trackers entry (61.9398ms)
✔ an unknown business and a broken system config refuse with exit 3 (90.565117ms)
✔ empty views say so (1.050744ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.353949ms)
✔ tasks print the tracker fields the snapshot carries (0.227491ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (985.301532ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (144.077175ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (76.333324ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (131.950258ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (132.857416ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (82.667306ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (122.687567ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (83.674749ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (142.595623ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (26.5632ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.026821ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (206.257684ms)
✔ bus start refuses with exit 3 without a notifier config (90.012691ms)
✔ bus start runs until bus stop; status reports it while it runs (596.03406ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.161421ms)
✔ zoned uses the IANA zone across DST (25.429466ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (31.691106ms)
✔ two blocking decisions get two DMs with different nonces (20.083014ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.319233ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (17.218038ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (24.851202ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (30.574446ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.555597ms)
✔ 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 (158.019254ms)
✖ a restart after the second refusal does not send before that refusal's 30 min are up (67.241172ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (20.292854ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.902027ms)
✔ an inbox read failure is logged and the next poll retries (0.789252ms)
✔ no Discord id reaches the journal or the log (14.374523ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (3.478343ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (3.574695ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.696529ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.758949ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (1.311343ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (3.666022ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.453856ms)
✔ 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.609212ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.436278ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.700138ms)
✔ digest content stays within Discord's 2000 characters (0.330256ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.126458ms)
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 (382.420474ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (55.083236ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2276.076705ms)
✔ busExit and refuseInsideAgent (0.39123ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.58026ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.486348ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.782136ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.534211ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (18.814758ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.814314ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (8.21186ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.842547ms)
✔ authorize: open channel, listed user (2.053887ms)
✔ authorize: wrong guild (0.202343ms)
✔ authorize: no guild (DM) (0.170271ms)
✔ authorize: unlisted channel (0.176836ms)
✔ authorize: unknown channel, no info (0.168717ms)
✔ authorize: thread of listed parent (0.196506ms)
✔ authorize: thread of unlisted parent (0.15738ms)
✔ authorize: text channel that is not a thread and not listed (0.171675ms)
✔ authorize: unlisted user (0.178809ms)
✔ authorize: no author (0.557715ms)
✔ authorize: bot author (listed id, bot flag) (0.241612ms)
✔ authorize: system author (0.147439ms)
✔ authorize: the bot itself (0.086306ms)
✔ authorize: webhook (0.118423ms)
✔ authorize: mention channel without mention (0.132895ms)
✔ authorize: mention channel with bot mention (0.143843ms)
✔ authorize: mention channel with @everyone only (0.10657ms)
✔ authorize: mention channel mentioning someone else (0.077662ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.12351ms)
✔ authorize: private thread under mention channel, mentioned (0.105465ms)
✔ authorize: private thread under mention channel, not mentioned (0.086038ms)
✔ authorize: thread in another guild per channel info (0.079013ms)
✔ authorize: not an object (0.065778ms)
✔ authorize: no id (0.066375ms)
✔ authorize: oversize content is accepted and flagged (0.074059ms)
✔ authorize: exactly the limit is not oversize (0.066989ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.565552ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.143183ms)
✔ binding: a complete binding validates and is frozen (2.656805ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.389076ms)
✔ binding: empty allowlists refuse (0.460087ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.229812ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (2.075655ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.256022ms)
✔ binding: file must be 0600, regular, not a symlink (1.821351ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.562417ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (112.924765ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.210818ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (426.874918ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (191.593308ms)
✔ cli: run refuses when STOP is present, before any network use (130.090866ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.534971ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.354584ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (19.977314ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (15.608763ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.407972ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.0526ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.277071ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.142759ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.723998ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.595978ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (2.116744ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.196179ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (43.671622ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (37.121541ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.730249ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (8.609241ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (4.780897ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (8.261007ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.238407ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.880169ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (6.537315ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.090148ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (3.805543ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (3.586145ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.446411ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.670732ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.808101ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.880131ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.187134ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.839132ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (2.040833ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.81323ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.216171ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.448431ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (52.681258ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (64.340402ms)
✔ engine: one prompt, one turn, text and usage come back (57.681623ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (356.924501ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.644049ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (103.734148ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (227.115891ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (129.923051ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.844154ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.259305ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.325464ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.518519ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (425.063998ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.407984ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (47.760636ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.422469ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.559985ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.47645ms)
✔ gateway: op 9 resumable resumes (0.356614ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.871165ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.526328ms)
✔ gateway: close() is final and unparseable frames are ignored (0.386158ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (92.075027ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (78.749711ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (125.588179ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.477862ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (116.633883ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (87.294943ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (83.764409ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (203.159841ms)
✔ git: push pushes the named branch only and reports up to date (64.374856ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (78.940244ms)
✔ git: the credential helper answers get over https from a private file and nothing else (202.792825ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (760.407252ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.505567ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (101.330217ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.204087ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (4.150497ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (63.528632ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (346.854604ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.450419ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (26.765004ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.993567ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (38.037028ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (136.745993ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (91.597577ms)
✔ notices: a kind is recorded per UTC day and found again (0.407662ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (6.206925ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.98158ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (2.101575ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (52.865116ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (56.187766ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (42.06983ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (84.559484ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (666.190228ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.458366ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.953106ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.843553ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.788709ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.967209ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (6.08603ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.294386ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.808292ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.003245ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (29.758678ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (12.575322ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (11.292108ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.305426ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.917032ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (6.21851ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1011.910582ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.450772ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (5.992484ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.226257ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.24979ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.326927ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.172966ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.869575ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (8.252117ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.613627ms)
✔ tools: listing and search caps hold (13.011719ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (1.010923ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (5.862506ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.464254ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.237546ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (7.302745ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (2.697192ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.944694ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.569048ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.387757ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1023.46693ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.438251ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.259496ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.814276ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.295039ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3187.624351
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:201:1
✖ a restart after the second refusal does not send before that refusal's 30 min are up (67.241172ms)
AssertionError [ERR_ASSERTION]: nothing between the restart and +60 min
+ actual - expected
[
+ 31.5
- 60
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:208:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [ 31.5 ],
expected: [ 60 ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,277 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (40.685297ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (37.484026ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (16.605654ms)
✔ decide prints a declining choice as declining (19.393014ms)
✔ an unknown outcome is reported once and never resent (17.852982ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (15.710979ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (29.266905ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (21.009526ms)
✔ every human command refuses inside an agent run before it touches the bus (24.758986ms)
✔ usage errors exit 4; no business and no host is a usage error (24.953573ms)
✔ agents and tasks print through the broker (22.864154ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.258424ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (76.228084ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.166251ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (58.550672ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (64.331044ms)
✔ a business without tracker.baseUrl gets no trackers entry (75.92909ms)
✔ an unknown business and a broken system config refuse with exit 3 (82.458244ms)
✔ empty views say so (1.087409ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.359589ms)
✔ tasks print the tracker fields the snapshot carries (0.225154ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (993.897545ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (152.938597ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (88.126377ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (137.108011ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (123.170596ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (73.85963ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (125.758845ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.267818ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (134.516445ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.177914ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.983501ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (205.420751ms)
✔ bus start refuses with exit 3 without a notifier config (88.934962ms)
✔ bus start runs until bus stop; status reports it while it runs (606.927416ms)
✔ bus-service.sh renders the unit and installs it into a given directory (26.293005ms)
✔ zoned uses the IANA zone across DST (25.704583ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (31.460666ms)
✔ two blocking decisions get two DMs with different nonces (30.675203ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.650589ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (25.821533ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (22.378617ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (25.083244ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (16.516437ms)
✔ 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 (157.893559ms)
✖ a restart after the second refusal does not send before that refusal's 30 min are up (63.495042ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (19.204607ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.886139ms)
✔ an inbox read failure is logged and the next poll retries (0.814837ms)
✔ no Discord id reaches the journal or the log (10.08282ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.04334ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.911381ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.771593ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.544469ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.622522ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.143025ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.363422ms)
✔ 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.556486ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.400861ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.481441ms)
✔ digest content stays within Discord's 2000 characters (0.293111ms)
✔ runLoop never overlaps ticks and stops after the one in flight (110.51501ms)
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 (379.245614ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (54.669042ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2285.747948ms)
✔ busExit and refuseInsideAgent (0.410368ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (3.374242ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.83547ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.705412ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.511744ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (18.198846ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.5266ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (9.967294ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.589031ms)
✔ authorize: open channel, listed user (1.93302ms)
✔ authorize: wrong guild (0.178537ms)
✔ authorize: no guild (DM) (0.292093ms)
✔ authorize: unlisted channel (0.179326ms)
✔ authorize: unknown channel, no info (0.28383ms)
✔ authorize: thread of listed parent (0.195098ms)
✔ authorize: thread of unlisted parent (0.241817ms)
✔ authorize: text channel that is not a thread and not listed (0.13626ms)
✔ authorize: unlisted user (0.84987ms)
✔ authorize: no author (0.770423ms)
✔ authorize: bot author (listed id, bot flag) (0.147142ms)
✔ authorize: system author (0.13532ms)
✔ authorize: the bot itself (0.103191ms)
✔ authorize: webhook (0.113308ms)
✔ authorize: mention channel without mention (0.146621ms)
✔ authorize: mention channel with bot mention (1.211073ms)
✔ authorize: mention channel with @everyone only (0.099513ms)
✔ authorize: mention channel mentioning someone else (0.071029ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.06969ms)
✔ authorize: private thread under mention channel, mentioned (0.079615ms)
✔ authorize: private thread under mention channel, not mentioned (0.057973ms)
✔ authorize: thread in another guild per channel info (0.065292ms)
✔ authorize: not an object (0.058305ms)
✔ authorize: no id (0.059287ms)
✔ authorize: oversize content is accepted and flagged (0.067696ms)
✔ authorize: exactly the limit is not oversize (0.066163ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.462736ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.148095ms)
✔ binding: a complete binding validates and is frozen (2.679076ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (2.750459ms)
✔ binding: empty allowlists refuse (0.397568ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.42358ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.835916ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.838789ms)
✔ binding: file must be 0600, regular, not a symlink (1.768289ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (1.242621ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (100.203101ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.042691ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (416.198477ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (188.38447ms)
✔ cli: run refuses when STOP is present, before any network use (136.668768ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.702683ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.180138ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (20.584103ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (18.902147ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.485136ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.29353ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.377209ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.244496ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.28341ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (2.977577ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.155398ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.51738ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (42.464805ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.459075ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (8.694062ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (6.111067ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.492061ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (7.839407ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.100361ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (0.836243ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (5.161106ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (2.17244ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.895196ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (4.386984ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (4.567214ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.789429ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.943608ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.051884ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.284424ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.523534ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.574205ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.897148ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.447591ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.425901ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (53.517271ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (54.764108ms)
✔ engine: one prompt, one turn, text and usage come back (60.690738ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (369.555677ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (228.567122ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (103.590577ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (232.506668ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (130.452014ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (213.77209ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (613.605397ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.379673ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.557131ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (428.116525ms)
✔ engine: a malformed JSONL line fails the turn, not the process (26.089845ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (44.887003ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.560753ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.638311ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.500406ms)
✔ gateway: op 9 resumable resumes (0.384574ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.85435ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.552787ms)
✔ gateway: close() is final and unparseable frames are ignored (0.540151ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (71.620654ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (83.665217ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (143.858301ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.442071ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (93.475596ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (85.861118ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (86.371812ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (195.730915ms)
✔ git: push pushes the named branch only and reports up to date (72.457254ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (87.279071ms)
✔ git: the credential helper answers get over https from a private file and nothing else (213.658166ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (768.286621ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.724785ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (92.605153ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.117381ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.133155ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (74.213903ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (359.688011ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.400101ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (27.700856ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.682573ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (40.085875ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (146.934264ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (88.516909ms)
✔ notices: a kind is recorded per UTC day and found again (0.444021ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.260902ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.702818ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.979157ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (51.549499ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (75.472143ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (50.592644ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (80.598114ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (651.842835ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.571622ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (5.380362ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (1.115207ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.693799ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (1.060569ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (7.078616ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.181245ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.833106ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.346432ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (28.150731ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (16.56649ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (8.789814ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (7.204342ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.880292ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (4.025895ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1013.141084ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.545979ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (7.007947ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.314484ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.215368ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.479213ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.058811ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.207036ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (5.443608ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (7.736511ms)
✔ tools: listing and search caps hold (13.678244ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.959099ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.77679ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.373321ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.317981ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.166706ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.209866ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.906211ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.477696ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.197139ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1024.553116ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.372419ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.266113ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.705228ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.225191ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3198.419333
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:201:1
✖ a restart after the second refusal does not send before that refusal's 30 min are up (63.495042ms)
AssertionError [ERR_ASSERTION]: nothing between the restart and +60 min
+ actual - expected
[
+ 31.5
- 60
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:208:10)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [ 31.5 ],
expected: [ 60 ],
operator: 'deepStrictEqual',
diff: 'simple'
}
@@ -0,0 +1,286 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (36.439821ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (33.925039ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (21.158075ms)
✔ decide prints a declining choice as declining (18.952008ms)
✔ an unknown outcome is reported once and never resent (16.952476ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (20.117146ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (16.377237ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (17.785936ms)
✔ every human command refuses inside an agent run before it touches the bus (16.701711ms)
✔ usage errors exit 4; no business and no host is a usage error (17.493924ms)
✔ agents and tasks print through the broker (17.802635ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.969558ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (65.153084ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (65.019891ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (63.315796ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (57.643913ms)
✔ a business without tracker.baseUrl gets no trackers entry (79.072851ms)
✔ an unknown business and a broken system config refuse with exit 3 (100.614075ms)
✔ empty views say so (1.321977ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.642474ms)
✔ tasks print the tracker fields the snapshot carries (0.24347ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (979.002116ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (149.546048ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (75.917239ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.077629ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (125.479723ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (74.096919ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (128.383414ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (75.365589ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (149.996979ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (24.844573ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.886459ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (209.271349ms)
✔ bus start refuses with exit 3 without a notifier config (89.833801ms)
✔ bus start runs until bus stop; status reports it while it runs (596.240557ms)
✖ bus-service.sh renders the unit and installs it into a given directory (13.023386ms)
✔ zoned uses the IANA zone across DST (25.081139ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (33.087017ms)
✔ two blocking decisions get two DMs with different nonces (21.832137ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.313202ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (20.727213ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (26.646629ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (26.996695ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (17.003301ms)
✔ 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 (154.842007ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (73.169397ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (20.197995ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (12.140882ms)
✔ an inbox read failure is logged and the next poll retries (0.826196ms)
✔ no Discord id reaches the journal or the log (12.640011ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.577376ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.291009ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.274937ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.572193ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.647559ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (3.528525ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.371869ms)
✔ 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.540285ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.444668ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.517872ms)
✔ digest content stays within Discord's 2000 characters (0.282252ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.600047ms)
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 (371.436455ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (64.122569ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2279.073027ms)
✔ busExit and refuseInsideAgent (0.386801ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (5.051726ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.451345ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.679177ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.505543ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (16.759513ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (6.847581ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (7.885012ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.733518ms)
✔ authorize: open channel, listed user (2.103689ms)
✔ authorize: wrong guild (0.204134ms)
✔ authorize: no guild (DM) (0.250642ms)
✔ authorize: unlisted channel (0.179972ms)
✔ authorize: unknown channel, no info (0.260484ms)
✔ authorize: thread of listed parent (0.444085ms)
✔ authorize: thread of unlisted parent (0.296317ms)
✔ authorize: text channel that is not a thread and not listed (0.141873ms)
✔ authorize: unlisted user (1.233958ms)
✔ authorize: no author (0.509405ms)
✔ authorize: bot author (listed id, bot flag) (0.144304ms)
✔ authorize: system author (0.325929ms)
✔ authorize: the bot itself (0.098491ms)
✔ authorize: webhook (0.66558ms)
✔ authorize: mention channel without mention (0.157236ms)
✔ authorize: mention channel with bot mention (0.138165ms)
✔ authorize: mention channel with @everyone only (0.089143ms)
✔ authorize: mention channel mentioning someone else (0.085157ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.083237ms)
✔ authorize: private thread under mention channel, mentioned (0.094689ms)
✔ authorize: private thread under mention channel, not mentioned (0.266387ms)
✔ authorize: thread in another guild per channel info (0.083028ms)
✔ authorize: not an object (0.07959ms)
✔ authorize: no id (0.070516ms)
✔ authorize: oversize content is accepted and flagged (0.084489ms)
✔ authorize: exactly the limit is not oversize (0.068505ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (0.465465ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (0.138041ms)
✔ binding: a complete binding validates and is frozen (2.574002ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.281521ms)
✔ binding: empty allowlists refuse (0.325095ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (1.384415ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (1.623987ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (1.200867ms)
✔ binding: file must be 0600, regular, not a symlink (1.649798ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (2.365167ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (112.716415ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (2.641304ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (435.544546ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (202.966592ms)
✔ cli: run refuses when STOP is present, before any network use (151.624562ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (1.8529ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.386448ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (22.641577ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (16.801329ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (2.489508ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.553887ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.715878ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (2.415618ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.013139ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.915224ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.294216ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.052537ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (44.144941ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (33.552931ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.922673ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (7.891341ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.13703ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (5.04555ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (3.47815ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.089163ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (8.646223ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (3.375862ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (4.101761ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (4.56979ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (8.744825ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (2.499542ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (0.776987ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (1.883161ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.241348ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (1.405812ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (0.999705ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.576183ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.819668ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.461278ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (55.109589ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (50.2841ms)
✔ engine: one prompt, one turn, text and usage come back (48.874702ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (358.597213ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (234.327562ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (115.980983ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (232.999465ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (127.109157ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (212.630669ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.344473ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.354236ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.500428ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (425.782047ms)
✔ engine: a malformed JSONL line fails the turn, not the process (24.521955ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.954091ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (2.575865ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (1.640094ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.494401ms)
✔ gateway: op 9 resumable resumes (0.680377ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (0.950533ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.5513ms)
✔ gateway: close() is final and unparseable frames are ignored (0.412734ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (79.181806ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (67.333024ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (163.8268ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.339034ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (96.931881ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (86.40052ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (89.861594ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (213.438671ms)
✔ git: push pushes the named branch only and reports up to date (79.995959ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (91.684346ms)
✔ git: the credential helper answers get over https from a private file and nothing else (198.939395ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (760.532022ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (4.518779ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (94.136648ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.321714ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.010352ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (84.932333ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (371.359177ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.953502ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (25.87851ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (1.08047ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (42.520075ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (162.423161ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (90.616142ms)
✔ notices: a kind is recorded per UTC day and found again (0.478887ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.466472ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.244007ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.967992ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (55.135659ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (68.081432ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (52.742578ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (92.661558ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (685.712976ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.555387ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (4.983774ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.512018ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.572608ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.682831ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (5.690252ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.4364ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.873619ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (2.484239ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (26.401741ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.780187ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.506372ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (4.523326ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.868572ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.473946ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1017.080749ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.417568ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.467204ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.171844ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.198172ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.465489ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.187242ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (4.395016ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (6.590029ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (3.883006ms)
✔ tools: listing and search caps hold (13.764368ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.895509ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.850724ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.464872ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.848609ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (6.002398ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.244872ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.849368ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.865861ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (2.229826ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1032.551897ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.2694ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.235987ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.621124ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.199604ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3152.644118
✖ failing tests:
test at packages/cli/tests/host.test.mjs:396:1
✖ bus-service.sh renders the unit and installs it into a given directory (13.023386ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /^ {2}mkdir -m 0700 -p <dataRoot>\/notify\/<business> +the notifier refuses a looser directory$/m. Input:
'written: /mnt/storage/scratch/tmp/mosaic-cli-L14dvV/[email protected]\n' +
'next, for one business (one per data root):\n' +
' write <dataRoot>/notify/<business>/notify.json, mode 0600:\n' +
' {"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs\n' +
' systemctl --user enable --now mosaic-bus@<business> start now and at login\n' +
' systemctl --user status mosaic-bus@<business>\n' +
" journalctl --user -u mosaic-bus@<business> -f the host's log\n" +
' systemctl --user stop mosaic-bus@<business> SIGTERM; restartable\n' +
'survive logout and reboot only with lingering on: loginctl enable-linger jwoltje\n'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/host.test.mjs:409:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: `written: /mnt/storage/scratch/tmp/mosaic-cli-L14dvV/[email protected]\nnext, for one business (one per data root):\n write <dataRoot>/notify/<business>/notify.json, mode 0600:\n {"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs\n systemctl --user enable --now mosaic-bus@<business> start now and at login\n systemctl --user status mosaic-bus@<business>\n journalctl --user -u mosaic-bus@<business> -f the host's log\n systemctl --user stop mosaic-bus@<business> SIGTERM; restartable\nsurvive logout and reboot only with lingering on: loginctl enable-linger jwoltje\n`,
expected: /^ {2}mkdir -m 0700 -p <dataRoot>\/notify\/<business> +the notifier refuses a looser directory$/m,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,287 @@
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (42.695821ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (50.900968ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (19.526315ms)
✔ decide prints a declining choice as declining (27.0715ms)
✔ an unknown outcome is reported once and never resent (17.942055ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (21.456887ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (20.232816ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (28.167704ms)
✔ every human command refuses inside an agent run before it touches the bus (23.232404ms)
✔ usage errors exit 4; no business and no host is a usage error (21.716825ms)
✔ agents and tasks print through the broker (26.685493ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (2.448765ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (101.758457ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (64.589268ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (60.806348ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (64.399373ms)
✔ a business without tracker.baseUrl gets no trackers entry (75.658057ms)
✔ an unknown business and a broken system config refuse with exit 3 (94.792104ms)
✔ empty views say so (1.146799ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.41943ms)
✔ tasks print the tracker fields the snapshot carries (0.361908ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1019.227828ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (152.084167ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (79.194062ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (128.995735ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (127.01056ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (76.219523ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (122.025954ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (72.468138ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (138.063567ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (23.990777ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.881279ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (206.23692ms)
✔ bus start refuses with exit 3 without a notifier config (83.701998ms)
✔ bus start runs until bus stop; status reports it while it runs (594.58956ms)
✔ bus-service.sh renders the unit and installs it into a given directory (27.364129ms)
✔ zoned uses the IANA zone across DST (26.624668ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (47.126452ms)
✔ two blocking decisions get two DMs with different nonces (35.118213ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.335013ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (31.814163ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (31.270483ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (29.440396ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (23.556561ms)
✔ 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 (170.013966ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (56.029087ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (15.519714ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (9.167494ms)
✔ an inbox read failure is logged and the next poll retries (0.705928ms)
✔ no Discord id reaches the journal or the log (10.038691ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.377141ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.279316ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.191204ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.485391ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.550256ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (1.966907ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.567178ms)
✔ 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.476903ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.377081ms)
✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path (1.505115ms)
✔ digest content stays within Discord's 2000 characters (0.457248ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.351089ms)
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 (406.719655ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (82.627037ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2314.22678ms)
✔ busExit and refuseInsideAgent (0.387445ms)
✔ approvals: a request is validated before anything is posted; the rendering shows names and never ids (4.949848ms)
✔ approvals: the ledger is appended and folded into open requests with bind and approval states (1.72578ms)
✔ approvals: a reply approves only when it points at a request, says exactly approve, and comes from a listed approver once (0.627445ms)
✔ approvals: a button approves only on its own request message with the matching custom id (0.547957ms)
✔ approvals flow: a turn that opened a request posts the message with the button, records it, binds it, and both approvers approve (32.452945ms)
✔ approvals flow: a non-approver, a repeat, a wrong custom id and a service refusal each get their fixed line and a drop entry (7.780424ms)
✔ approvals flow: an invalid request from the model, a refused post, and no api client are recorded and post nothing (15.756427ms)
✔ approvals flow: start retries a bind and an approval left as unknown, under their original keys (2.716303ms)
✔ authorize: open channel, listed user (2.116809ms)
✔ authorize: wrong guild (0.218948ms)
✔ authorize: no guild (DM) (0.178971ms)
✔ authorize: unlisted channel (0.220612ms)
✔ authorize: unknown channel, no info (0.226098ms)
✔ authorize: thread of listed parent (0.216643ms)
✔ authorize: thread of unlisted parent (0.206414ms)
✔ authorize: text channel that is not a thread and not listed (0.129743ms)
✔ authorize: unlisted user (0.399689ms)
✔ authorize: no author (0.257888ms)
✔ authorize: bot author (listed id, bot flag) (0.158293ms)
✔ authorize: system author (0.113693ms)
✔ authorize: the bot itself (0.105945ms)
✔ authorize: webhook (0.088276ms)
✔ authorize: mention channel without mention (0.133082ms)
✔ authorize: mention channel with bot mention (0.171777ms)
✔ authorize: mention channel with @everyone only (0.118598ms)
✔ authorize: mention channel mentioning someone else (0.102448ms)
✔ authorize: mention channel, content says @bot but mentions empty (0.188459ms)
✔ authorize: private thread under mention channel, mentioned (0.108392ms)
✔ authorize: private thread under mention channel, not mentioned (0.086117ms)
✔ authorize: thread in another guild per channel info (0.095182ms)
✔ authorize: not an object (0.060444ms)
✔ authorize: no id (0.064663ms)
✔ authorize: oversize content is accepted and flagged (0.087816ms)
✔ authorize: exactly the limit is not oversize (0.065254ms)
✔ authorize: a user's channel allowlist drops them outside it, threads count as the parent, others are unaffected (1.91215ms)
✔ authorize: order puts wrong guild before user, and user before channel (no channel lookup for strangers) (1.146256ms)
✔ binding: a complete binding validates and is frozen (2.615019ms)
✔ binding: unknown key, missing field, wrong type refuse with exit 2 (1.337072ms)
✔ binding: empty allowlists refuse (0.895847ms)
✔ binding: a user's channel allowlist must be non-empty, listed and unique; absent means every listed channel (2.916197ms)
✔ reloadDiff: reloadable keys are summarised by id; every fixed key refuses with exit 2 (4.098608ms)
✔ binding: dmRecipient is optional, a snowflake, one of the listed users, and a fixed key (0.827967ms)
✔ binding: file must be 0600, regular, not a symlink (2.909621ms)
✔ binding: token file mode, symlink, emptiness and shape are checked; token never appears in errors (2.370774ms)
✔ cli: check refuses a non-0600 token file with exit 2 before any network use (131.540131ms)
✔ context files: absolute paths, traversal, symlinks and out-of-repo targets refuse; in-repo files resolve (3.987938ms)
✔ cli: check refuses a missing context file and a missing binding with exit 2; usage is exit 4 (416.545817ms)
✔ cli: reload validates the file first (exit 2), then needs a live owner (exit 1); usage is exit 4 (204.124078ms)
✔ cli: run refuses when STOP is present, before any network use (172.458112ms)
✔ binding: tools is optional, validated strictly, a fixed key for reload, and its roots are resolved against the data root (2.076345ms)
✔ binding: a git key is validated at load and reaches the extension whole, and only on a writable root (1.564452ms)
✔ delivery: an accepted message is in the inbox before the turn, the reply is chunked with one nonce per chunk, and the turn record is write-once (24.99628ms)
✔ delivery: refused and unknown outcomes are journaled; a later chunk is not sent after a failure (20.642317ms)
✔ delivery: restart with an unknown entry re-sends the same nonce once and reconciles before accepting traffic (1.767332ms)
✔ delivery: an unknown entry older than the dedupe window is marked refused, not re-sent; a still-unknown one refuses start (1.357443ms)
✔ delivery: repeated unknown reconciliations never refresh the dedupe window; the original intent time decides (1.610001ms)
✔ turn: a failed engine turn posts the fixed line, never model output, and writes a failed record (3.67124ms)
✔ turn: a second message during a turn is held by the engine, both get their own reply and record (34.36697ms)
✔ turn: a thread under a listed channel is answered in the thread; an unknown thread is looked up once (3.821187ms)
✔ drop: an unlisted user gets silence and one drop line; no inbox entry, no REST call, no engine call (1.667123ms)
✔ drop: an oversize message is accepted into the inbox, answered with the fixed line and journaled as a drop (1.204315ms)
✔ restart: an inbox with three ids and a replay of the same three produces zero turns (44.147074ms)
✔ stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes (32.723872ms)
✔ ceiling: the ceiling plus one is refused and journaled; one fixed line per UTC day; a new day accepts again (7.999848ms)
✔ ceiling: a burst arriving while turns are still running cannot queue past the ceiling (4.400002ms)
✔ ceiling: a turn interrupted by a crash still counts after restart; admissions are durable (3.421125ms)
✔ ceiling: the daily notice survives a same-day restart; one delivery attempt in total, even when the first attempt crashed mid-flight (6.035732ms)
✔ duplicate: the same event delivered twice while the thread lookup is held yields one prompt, one admission and one reply (4.577778ms)
✔ journal: no token-shaped string and no model output on the drop path reaches disk (1.049663ms)
✔ receipt: an admitted message gets one eyes reaction on the inbound message; drops and refusals get none; a failed reaction is recorded and does not fail the turn (4.606455ms)
✔ receipt: Discord refusing the reaction leaves the turn intact and records ok false (1.945793ms)
✔ reload: a new user is silent before and answered after; a removed channel goes silent; a lower ceiling applies at once (5.902214ms)
✔ reload: a fixed key refuses with exit 2 and the old binding stays in force (1.995553ms)
✔ tools: with a tools binding the turn record lists every read and its outcome; without one the field is null (5.633274ms)
✔ context: the Discord block names the server, channels and modes, and states the rules from Q15 and Q16 (8.128078ms)
✔ context: with tools the block names the roots, keeps file content as data, and says to state refusals plainly (1.086506ms)
✔ context: a writable root adds the write rules and says a write is real only once Jason commits (2.245325ms)
✔ context: the envelope is one bracketed line then the text; names cannot break the line (1.225153ms)
✔ context: a git root swaps the terminal-commit line for the git verbs, and a vault root adds the id protocol (2.571003ms)
✔ context: assembleContext concatenates files in launcher format and appends the block; sha256 is stable (1.986458ms)
✔ context: splitReply keeps paragraphs together under the limit and splits long ones at lines, spaces, then hard (0.730167ms)
✔ engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file (2.59808ms)
✔ engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three (0.477007ms)
✔ engine: a run with tool turns settles once, on the answer, with every tool call in the result (70.434554ms)
✔ engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end (59.656196ms)
✔ engine: one prompt, one turn, text and usage come back (54.286738ms)
✔ engine: a prompt while streaming is held until pi settles, then sent as its own run, and answered in order (378.18188ms)
✔ engine: a held prompt that times out before pi settles fails on its own and is never sent (235.133027ms)
✔ engine: timeout sends abort and fails only that turn; the process stays (105.242619ms)
✔ engine: tool events from a run that outlived its timeout never land in the next prompt's record (236.697114ms)
✔ engine: a prompt after a turn that timed out before its agent_start waits for pi to settle instead of being refused (124.481269ms)
✔ engine: when pi has not started a timed-out turn by the end of the abort grace, the engine stops pi and fails held prompts (214.036359ms)
✔ engine: a timed-out turn pi starts only after the grace never answers a later prompt (614.793151ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (early prompt response) (1.392011ms)
✔ engine: late events of a run past its grace, before pi exits, answer nothing and nothing more is sent (late prompt response) (0.514755ms)
✔ engine: a timed-out run pi did start outlives the grace; the next prompt goes out when it ends (426.246807ms)
✔ engine: a malformed JSONL line fails the turn, not the process (25.181641ms)
✔ engine: a turn that ends in error rejects with the error code; process exit fails pending turns (45.58512ms)
✔ gateway: hello -> identify with intents, ready, heartbeat with jitter, ack (4.210163ms)
✔ gateway: missed ack closes the socket and resumes with the last sequence (2.021905ms)
✔ gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies (0.565785ms)
✔ gateway: op 9 resumable resumes (0.947653ms)
✔ gateway: close 4014 is fatal, reports the missing intent, never reconnects (1.704494ms)
✔ gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session (0.58659ms)
✔ gateway: close() is final and unparseable frames are ignored (0.970406ms)
✔ git: config validation is strict, needs write: true, a work tree and a private token file (79.348559ms)
✔ git: the child environment drops every host git config, names one helper, and carries the token path only for origin (63.850933ms)
✔ git: status reports the branch, ahead/behind and changed paths, and refuses off the named branch or mid-merge (162.955784ms)
✔ git: parseStatus reads porcelain v2 including renames and conflicts (0.305959ms)
✔ git: a commit stages exactly the named files, carries the seat author and the requester trailer, and pushes at once (86.971312ms)
✔ git: commit refusals: message, paths, requester, nothing to commit, and an index that already holds other work (94.214462ms)
✔ git: a commit whose push fails is still a commit, says so, and the next commit's push carries both (D6) (90.400645ms)
✔ git: pull is fast-forward only; a diverged origin or dirty local files refuse with nothing merged (235.813734ms)
✔ git: push pushes the named branch only and reports up to date (73.935437ms)
✔ git: no token value or token path ever reaches a git argument list; outputs are masked and capped (96.956611ms)
✔ git: the credential helper answers get over https from a private file and nothing else (198.660681ms)
✔ git: the vault protocol validates before a commit, honours another owner's lock, reserves ids, and locks around writes (752.56004ms)
✔ lock: the claim is exclusive; a second start against a live owner refuses (6.081677ms)
✔ lock: a stale lock (dead owner, reused pid, or record without start) refuses run and is never signaled; only unlock clears it (108.168434ms)
✔ lock: an incomplete claim (directory without owner record) is busy and refuses run; unlock clears it (1.226863ms)
✔ lock: an owner record that exists but cannot be read is invalid: never signaled, never removed, never claimed over (5.647633ms)
✔ lock: legacy upgrade; a live connector holding a {pid, start} record is unknown, unlock refuses and nothing changes; after it exits, unlock clears it (76.813084ms)
✔ lock: a live pid whose record carries a malformed or noncanonical start or boot string is unknown, not a mismatch; nothing signals, removes, or claims over it (342.479082ms)
✔ lock: identity syntax; only canonical unsigned decimal start ticks and lowercase boot uuids are identities (0.380568ms)
✔ lock: a process whose start marker or boot id cannot be read refuses to claim (27.340548ms)
✔ lock: a live pid whose identity cannot be read right now is unknown: never signaled, never removed, never claimed over (0.986595ms)
✔ lock: four processes racing for the same binding; exactly one claims it and the others refuse (46.034157ms)
✔ lock: stale handoff; concurrent starts over a stale lock all refuse, nothing reclaims, one unlock then exactly one live owner (181.634629ms)
✔ lock: four-party schedule; claims landing inside an unlock's gap never survive, one unlock leaves no owner and no residue (93.79815ms)
✔ notices: a kind is recorded per UTC day and found again (0.419578ms)
✔ notify: the DM channel opens once, every send carries the nonce, and only the message id comes back (5.511894ms)
✔ notify: refusals and unknowns surface as RestOutcome without ids; a refused send reopens the channel next time (2.573585ms)
✔ notify: no dmRecipient, a non-0600 binding or token, or a missing binding refuse before any network use (1.750235ms)
✔ recover: nothing to do is clean; a lock whose owner is gone or that has no record is cleared and STOP ends up absent (52.481055ms)
✔ recover: an operator STOP refuses with exit 3 and is never removed, whatever the lock says (61.120525ms)
✔ recover: a brake written during the unlock wins; STOP stays with both lines and the start is refused (59.098891ms)
✔ recover: a held binding refuses with exit 3 and writes no STOP: live owner, alive pid without verifiable identity, unreadable record (86.863926ms)
✔ cli: recover exits 0 when ready, 3 behind a brake or a held binding, and run's own STOP refusal is 3 (716.13454ms)
✔ rest: createMessage sends nonce, enforce_nonce, empty allowed_mentions and a soft reply reference (2.642214ms)
✔ rest: 429 waits retry_after and retries; 4xx is refused; 5xx and socket errors are unknown (2.228517ms)
✔ rest: content and nonce limits are enforced locally; typing never throws (0.589473ms)
✔ rest: react PUTs the encoded emoji on the inbound message for @me; 2xx is true, anything else is false and never throws (0.660656ms)
✔ rest: createDm posts the recipient and resolves the channel id; 4xx refused, 5xx unknown, bad id never sent (0.849316ms)
✔ setspark config: a bare https or loopback origin, a private key file, a principal (7.93533ms)
✔ setspark config: reaches the tools config and the binding as a fixed key (3.559885ms)
✔ setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension (1.871351ms)
✔ setspark config: approvers come from the binding's users, never from the binding's setspark key (3.039388ms)
✔ setspark verbs: required_approvers go out as discord ids from names and come back as names (31.865221ms)
✔ setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in (13.500828ms)
✔ setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse (9.361846ms)
✔ setspark keys: read per call, one printable token per file, rotation without a restart (5.723068ms)
✔ setspark idempotency keys: principal, turn id, call index; connector keys name a step (0.617638ms)
✔ setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else (3.405558ms)
✔ setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut (1013.946075ms)
✔ setspark verbs: a setspark key enables the eight verbs and no counters (0.473246ms)
✔ setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details (6.532815ms)
✔ setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work (1.239307ms)
✔ setspark verbs: renderRecord caps long output and hides the accepted snapshot (0.20801ms)
✔ setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys (2.714297ms)
✔ tools: config refuses a missing, symlinked, dotted, non-directory or duplicate root and bad limits (4.036644ms)
✔ tools: every escape is refused with a fixed reason and nothing outside the root is read (6.208638ms)
✔ tools: happy paths list, read a window, and search case-insensitively; dotfiles and symlinks never appear (7.991555ms)
✔ tools: the tool set renders text for the model, records details for the journal, and enforces the per-run budget (4.727937ms)
✔ tools: listing and search caps hold (11.975375ms)
✔ tools: credential shapes are caught; ordinary prose and ids are not (0.948319ms)
✔ tools: the read uses the checked file itself; a symlink, a swapped file, a FIFO, a grown file or a hard link at read time is refused (6.128059ms)
✔ tools: an unreadable file under the root is skipped by search and refused by read (1.324429ms)
✔ tools: config accepts write: true only as a boolean, and enables the write tools only then (1.347562ms)
✔ tools: every write outside the fence is refused before any byte lands, and no temp file remains (5.563725ms)
✔ tools: write_file leaves the exact bytes, edit_file replaces one exact match, and the set renders the change as uncommitted (3.24243ms)
✔ tools: a target that changed between the check and the rename is refused and the temp file is removed (1.660983ms)
✔ web: config takes an https or loopback-http SearXNG base url and a bounded fetch cap (3.329986ms)
✔ web: address rules refuse every private, loopback, link-local, mapped and multicast form (1.890631ms)
✔ web: web_fetch refuses bad urls, private hosts, rebinding names, non-https redirects, too many hops, error status, non-text bodies, and times out (1028.336229ms)
✔ web: web_fetch returns html as text with the title, follows an https redirect, keeps plain text and json, and cuts at the cap (4.273426ms)
✔ web: html to text drops scripts, styles and comments, decodes entities and keeps block breaks (0.244886ms)
✔ web: web_search asks the instance for json, returns at most ten clean results, and refuses a bad query, a down instance or an unusable answer (2.652888ms)
✔ web: the tool set enables the web tools only with a web key, counts them in the budget, and records url, status and hits (2.40933ms)
ℹ tests 244
ℹ suites 0
ℹ pass 243
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3187.126372
✖ failing tests:
test at packages/cli/tests/notifier.test.mjs:410:1
✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path (1.505115ms)
AssertionError [ERR_ASSERTION]: The validation function is expected to return "true". Received false
Caught error:
Error: EACCES: permission denied, open '/mnt/storage/scratch/tmp/mosaic-cli-tXUsQU/notify/demo/sent.jsonl'
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:423:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: EACCES: permission denied, open '/mnt/storage/scratch/tmp/mosaic-cli-tXUsQU/notify/demo/sent.jsonl'
at openSync (node:fs:779:18)
at openJournal (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/src/notifier.mjs:182:10)
at file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:423:23
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r45/tree/packages/cli/tests/notifier.test.mjs:423:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19),
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,2 @@
3.85 2.71 2.46 3/12539 879606
2026-10-09T14:08:50Z
@@ -0,0 +1,2 @@
2.44 1.79 2.16 2/12979 779337
2026-10-09T14:05:56Z
@@ -0,0 +1,31 @@
N2 killed (fail 1, cancelled 0) ✖ the journal: a symlinked directory refuses and says it is a link;
N4 killed (fail 1, cancelled 0) ✖ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it;
N5 killed (fail 1, cancelled 0) ✖ watchChildren reports a child that died before it was called, and one that dies later;
M28 killed (fail 1, cancelled 0) ✖ a second host for the same data root refuses with exit 3 while the first runs;
G150 killed (fail 1, cancelled 0) ✖ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker;
G144 killed (fail 1, cancelled 0) ✖ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker;
F2a killed (fail 2, cancelled 0) ✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count;✖ 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;
F2b killed (fail 1, cancelled 0) ✖ 429s, 5xx-style unknowns and refusals without a status never count toward the limit;
F2c killed (fail 2, cancelled 0) ✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing;✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count;
F2d killed (fail 1, cancelled 0) ✖ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing;
F2e killed (fail 1, cancelled 0) ✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count;
F2f killed (fail 2, cancelled 0) ✖ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count;✖ 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;
J4a killed (fail 1, cancelled 0) ✖ the journal: a line with a wrong type refuses with exit 3 and names the field;
J4b killed (fail 1, cancelled 0) ✖ the journal: a line with a wrong type refuses with exit 3 and names the field;
J4c killed (fail 1, cancelled 0) ✖ the journal: a line with a wrong type refuses with exit 3 and names the field;
J4d killed (fail 1, cancelled 0) ✖ the journal: a line with a wrong type refuses with exit 3 and names the field;
E1 killed (fail 1, cancelled 0) ✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path;
E2 killed (fail 1, cancelled 0) ✖ the journal: a symlinked directory refuses and says it is a link;
G144cb killed (fail 1, cancelled 0) ✖ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error;
G150cb killed (fail 1, cancelled 0) ✖ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3;
G184 killed (fail 1, cancelled 0) ✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1;
G188 killed (fail 1, cancelled 0) ✖ close() whose stop and close sends fail with EPIPE still finishes, with exit 1;
R2a killed (fail 2, cancelled 0) ✖ a failed DM is journaled, backs off, and is retried until it lands;✖ 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;
R2b killed (fail 1, cancelled 0) ✖ a restart after the second refusal does not send before that refusal's 30 min are up;
R2c killed (fail 1, cancelled 0) ✖ a restart after the second refusal does not send before that refusal's 30 min are up;
X9 killed (fail 1, cancelled 0) ✖ the journal: a directory it cannot write or create refuses with exit 3 and names the path;
X14 killed (fail 1, cancelled 0) ✖ bus-service.sh renders the unit and installs it into a given directory;
D3 killed (fail 1, cancelled 0) ✖ the journal: a line with a wrong type refuses with exit 3 and names the field;
N1a killed (fail 1, cancelled 0) ✖ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3;
N1b killed (fail 1, cancelled 0) ✖ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3;
N1c killed (fail 1, cancelled 0) ✖ 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,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
@@ -0,0 +1,23 @@
On branch refactor
Your branch is up to date with 'origin/refactor'.
nothing to commit, working tree clean
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
@@ -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
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,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: 745170 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
@@ -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
@@ -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 148
ℹ pass 148
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/mnt/storage/scratch/rocko-r45/tree) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,7 @@
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
skip state-machine cases (docker daemon unavailable)
selftest: 4 passed, 0 failed
@@ -0,0 +1,33 @@
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
FAIL user recall run succeeds (exit 1)
FAIL recalled user name (response: )
OK no agent identity on headless run
selftest: 26 passed, 2 failed
@@ -1,16 +1,20 @@
fd7c9a28364d61669889180bce1482449ab0a583228ebb9e0e8fe5075cbe5eb0 BUILD.md
c88b20e4bd185682c82a4ce1733cd5b5a23a5375a10e9de55f2af5f8af7474a9 base.txt
4ed9ff61b94b4c9d2426e5703c2e6c0dbcd6bc0c64a88d59347a8b47f4933408 build.patch
b329fdcbdf8cb62659f239359570dfa9771559e3112b743df55429244769ed14 candidate-manifest.sha256
ff1af113dc9e41699cd697b39cc2f66a89b8cf3febbede8fb87365faa4cd4fb9 BUILD.md
b084be74fff27adc1b15158404edbbfee3efe3ac5a2288efa63ebc1a76bebd0d base.txt
c8cec070da60d8297f1ee5b006a1099ab9376fd3abf32fa4461f967750ad6756 build.patch
5b067a9dad645c31d99e1ea02575cd2fc1ba547ce011ea81c56b17ae29da0d0e candidate-manifest.sha256
8d35f99580981064d08725cf8e3dd67885bff77603446b0022701d4357727273 files.txt
bbe45c2a578b35cc2559972a4ee8900bd01ffb69e72bef6472dfc7e147e8916f mutants.sh
ecf3b48d64d2c73dda8fff357aabc9e8e719920a517d7172a02fce3c240d3756 mutants.sh
1102fd4f14df908f0498d31d209023d7903ba090c6c3bf3e3338e3f52158d95d out/base-node-conversation.txt
8df77c49bf82833efa56d42401a4a06f20931b7a76dd5eed3a73fdec60e92e79 out/base-suite-task.txt
795771fa8a721586343c05dac647306752fad14e06d738bad72af5bd388e3d59 out/gate-r2-end.txt
70f96042dfa6380b6d505a50401ecf16ad87f8daf653a5b295bd43fde6d753fd out/gate-r2-exits.txt
3a0e4de0a31a7fc5ecff3979aadfd495d0c12599e361323aca6101d78a964522 out/gate-r2-start.txt
2fc28669728c05f1f3f2c61e3c3a8fd88e7d13233856739428e772992bfc280b out/load-end-r2.txt
729a1baad006dde35b0b214386114ae6a0406ce302891a6e6aab32af52d025a5 out/load-end.txt
a0f662486a414610599959e6ea14dc9a80ec8ac266b0d4a98f5f26ec16b95396 out/load-start-r2.txt
a78af172868215584d99e036989cb88475fd5eecf8b2ac1775fd68aeb61af76e out/load-start.txt
e1bd7a5920b70115d2251ae3289799bb446445703b9da51c55c668f9cedfa461 out/manifest-check-mut.txt
e1bd7a5920b70115d2251ae3289799bb446445703b9da51c55c668f9cedfa461 out/manifest-check-tree-r2.txt
e1bd7a5920b70115d2251ae3289799bb446445703b9da51c55c668f9cedfa461 out/manifest-check-tree.txt
95bdcddfb56d633c72c0b8c18948cffd42e6c1b2aaa3b78800783eb2d1452e35 out/mut-E1.txt
1e13a94767e411e0eccf09d8b362dba095e33b6403f3d9bc5cde3e7cdfe62315 out/mut-E2.txt
@@ -31,26 +35,82 @@ e75b166d3aa8f605d394ed5449c20a11ed884c910c756230697d4f387e13cd50 out/mut-J4a.tx
a6dc80efac48f9de98748433fcac165b4cc3bf000de2b4c04d9107c9b3dfd342 out/mut-N4.txt
a0df618c85c05350971ed56203a4d8f37e8a6cc0fa2ba267de0ba34fd197523f out/mut-N5.txt
1225000966c8c8c2231ff7091665950f17a6d9d756a46f48b4ae3a8400aa635d out/mutants.txt
e0476e93931dc2fea8ce2d77174ce3ddedc30592281d4f0234c1d2e12049e693 out/node-bus-r2.txt
f14fe1ee4239ae63dfb1bea143d1d450905387a34abbcb9e4c6b09104b75aefa out/node-bus.txt
cc54ca08e481c76ebe389fd8e4686c7f1cd912a7cecb017adfaf91e23d040354 out/node-business-r2.txt
1a88ea66c4b4e6cbf23a420628b53082a770828fb10ddf3ae49cd5b65f4855d0 out/node-business.txt
4bf6f80ff71513010f9e8bcb17924ca7d346ea7f0fe0362404a0c94a4cc35e80 out/node-cli-r2.txt
099d2019d935d5c49cecff107316b040a93959ea832375cc008cb8c991004174 out/node-cli.txt
8e1a41b889b71cdcaee93c3ed4bc14e9c065048f750488069343b82e1178e7bb out/node-control-board-r2.txt
9d2ef4cf44c714e9d18a2162b1c34aad8867ca631d66253cf08c11c96dd6f472 out/node-control-board.txt
254a01cfe6a9516172de1368f6d701e5cfb42929174b9ed3424196666eadac7f out/node-conversation-r2.txt
bb4541854d7f2ced39d032f84f36088be1961ec7cf7c591c1196c94157c61858 out/node-conversation.txt
6db90f6b20cf0b50eca8d95b4488f999b4f53ba044bacc47a054c15c372e691a out/node-discord-r2.txt
9643c28e1490621836a1c64e2b62fad8f44033965cfc68df49187ff5e7c62c33 out/node-discord.txt
609bc17c7693d64f49120761b7f60712fbce191f5bbdb26fbb1e56b201f4477f out/node-ledger-r2.txt
c5fdadd964fdccc88182f91fa2b724ec7b69d1246b972c25536cc398800127dd out/node-ledger.txt
01bbb89694e16d8b0af8f6da010b931fbf202ae73a97b220afbf675d849cc4e3 out/node-mosaic-r2.txt
fc0d61d1a3695c44bc5e698523bd790e68bfde3785f38526c601bee852ad3784 out/node-mosaic.txt
8b86072a96cc9526d57cbb04f2812fa1b0b884272439538788154d42f6393169 out/node-queue-r2.txt
e2e08d74f8cb590e75d50bc011ca5875d8d49c1470e7f9ee9173cba39946eec9 out/node-queue.txt
33da17d4998d61f2628648ed5ab04ddd8aff65e231267676ae983a2eac45814c out/node-seat-r2.txt
74dbaa3099f9e3e88219cdc2ae4b83f21fc18ddafe060b299c1dcc5eacf95528 out/node-seat.txt
cdef1d30ea8e68e2701bc950d233a0f36fdd41613306f55198b1a3822bd72d2c out/node-tasks-r2.txt
34e473be5528b430fb28d9e78b59092e4bedf8660306590b24b6083ca844a697 out/node-tasks.txt
2f2e8dd3922e2d6c737ae636e0eefc02678aaf0d0e37e17f0c5b15cb007ccadf out/node-webui-r2.txt
a911209250eef9dbdbb7c762655b29811b42198cf439c363c601993ac30e1a3c out/node-webui.txt
e1bd7a5920b70115d2251ae3289799bb446445703b9da51c55c668f9cedfa461 out/r2/manifest-check-mut.txt
71bc7a5b9376362707c79fa0c7e0037058d22f54de5394f448e390dc6e2faa4a out/r2/mut-D3.txt
b3dea3c963be86ee51c91282cea5540bff7ab49f4f8031d99c089aae7e47d628 out/r2/mut-E1.txt
10f2e9db7f01fa8940bb9eaa25caac515efc31b0d4097585818eacb5b7898f87 out/r2/mut-E2.txt
4abaeec9660967e5e3ffa15bcfccdda878614ad6b11ee3884626d6a7f9aee985 out/r2/mut-F2a.txt
1e3f85e428b630abc810a76843f1ca9bb9ad10c357553892c764782a7d591b1d out/r2/mut-F2b.txt
891f2876e90ca1feceb284f7dbceb47c4f6dc9048acca4eaa0eaa579a1eeaada out/r2/mut-F2c.txt
c4f702a00f877a1ab60001e76ae88af54c1f54d4b38b9bfe8d2332516f108d47 out/r2/mut-F2d.txt
eb114215eca32c40514f3bae0cc060e19364d41507da306f25b498a01ae09a85 out/r2/mut-F2e.txt
ae7a3521ae600758fa00561ee94f666491ea334accf6c587b536a58a4db81392 out/r2/mut-F2f.txt
2e347ed278b09249815aff36c2d6918dac66e5abb1be5472c3c2296e63d9d28c out/r2/mut-G144.txt
9e17773ef5226ee5be34b779a5fbd2836a6afb40dd46d3835e7ff9f47330f351 out/r2/mut-G144cb.txt
ca18292aebbb750e23f6868f83af114042939ea027e9bfb218217733bf221d50 out/r2/mut-G150.txt
6a33017f5886c876e1294a7edba9d2dc77632bc1c743ce617be11476d7d3d9a7 out/r2/mut-G150cb.txt
44012b9779bfec8d8eeb5b63a8cf0779d40c807e0549df0154254f8a70f39412 out/r2/mut-G184.txt
0231a1718a60e7113b00a85d4283204a9f141a0261f28340e804defa77aee97a out/r2/mut-G188.txt
c841dab84d16c56f9ddeb6a8b6596d470a95560960a0c244857069bd41f254b0 out/r2/mut-J4a.txt
7bdb2636cc19b882475c537359eb4c491746938eb8ac77e6c973755ad810ae2f out/r2/mut-J4b.txt
e0b821077d8f5ac301d2a9575ad74c9ec3cd8c5024c8a58963bcf5d4076280f2 out/r2/mut-J4c.txt
ad759eeb23e69bb390b309efc8e753383de2a6183a9468c6d75bad5e6e292b2f out/r2/mut-J4d.txt
742e9d4485c22a7e87c1f52baccf4eba0f7d3191ec43e8b4f19533b41b757503 out/r2/mut-M28.txt
8c6e9dcdf5205c8cf5955cd4c54dbe858d72184396ba4a9a8eda479096900d58 out/r2/mut-N1a.txt
4600580bfd983aaff48d3b052f70e3822e5f47855b141d83d5122479a7d73d86 out/r2/mut-N1b.txt
9649c89a838906e57644719fa3a9ce7b4d476ce32f1b7d60a69f728f17d44198 out/r2/mut-N1c.txt
5eee699126b0efa5cd73d2b7ab898a28f01eea27d9258feb6ee92da1ad4ec594 out/r2/mut-N2.txt
0ec34dae8a5ce26d319a57af50c188451fcac7790fefc06e46069ca293c8e5c5 out/r2/mut-N4.txt
c79e920c1af4c40cc78651c7f50e9d1934303b577798771513f241c7dbd7f228 out/r2/mut-N5.txt
c599f60479601056d702137aa632aa01689ec2b2fbc6c300bd7fa4c0478c14c9 out/r2/mut-R2a.txt
047ae41126b9f0fb47918bcf4c57e8d75c3be1cfae82ad4a04fbc5243c513f22 out/r2/mut-R2b.txt
1c808e2f92791046aa30f12f5cc5c38cfd7713b30196d056a9cbca3ffcb53bdb out/r2/mut-R2c.txt
b5dd327fef18a3d27a8b6a3235e5cda327fa4ba3323fc639220fe9b5e6fab0d1 out/r2/mut-X14.txt
44d018a5d7d6d3a9a87f6af426b8813f1f222a6c5ff14728e14f0a562fe3a677 out/r2/mut-X9.txt
d3683280d195b7476b72ff0481d1c874c3252b7da5fd75a7233bc78e282a0643 out/r2/mut-end.txt
174f543b5500166f6ff4a11330e8fb514148f9cf8a2a88479c9251daf6b552df out/r2/mut-start.txt
065fec22c61273a215108713edcace910193705697e91248b1783efbf4546c25 out/r2/mutants.txt
8eaa5212718b37938ab792b00210ad00c7b793259ae9e0befc6f7b0316c1881e out/run.txt
f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 out/suite-auth-r2.txt
f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 out/suite-auth.txt
e1206366f07f3d9029530dcdcb55c0282d3f052adc60fc7fd51642d5d4ec3600 out/suite-conductor-r2.txt
e1206366f07f3d9029530dcdcb55c0282d3f052adc60fc7fd51642d5d4ec3600 out/suite-conductor.txt
52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 out/suite-config-r2.txt
52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 out/suite-config.txt
7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe out/suite-discord-r2.txt
7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe out/suite-discord.txt
4f840b0038586f0227c7f03914fdcb98bf9271068e3830183a877e5ac427d0d0 out/suite-extension-package-r2.txt
e4c762a4d4225b2f3eab0e37cba1d36126e67465557501196b91c2ffdd576e0e out/suite-extension-package.txt
83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f out/suite-foundation-r2.txt
83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f out/suite-foundation.txt
bf5db498b82546af2d78a218a1d6446da993de629f4819fffeecf690ef93a314 out/suite-queue-r2.txt
bf5db498b82546af2d78a218a1d6446da993de629f4819fffeecf690ef93a314 out/suite-queue.txt
6183e6b9b05edb497e92ffd09a19bba49c6f656c750ecb2acc283021113d1a98 out/suite-release-r2.txt
6183e6b9b05edb497e92ffd09a19bba49c6f656c750ecb2acc283021113d1a98 out/suite-release.txt
8df77c49bf82833efa56d42401a4a06f20931b7a76dd5eed3a73fdec60e92e79 out/suite-task-r2.txt
8df77c49bf82833efa56d42401a4a06f20931b7a76dd5eed3a73fdec60e92e79 out/suite-task.txt
50ceb870e3f56cac0ea04c7f7995a5eb5ec4e66f9c09a92a9fc7705e1df896cf run.sh