row 302 (#1809, Vikunja 588): red first, the final forcing tests and the four pins Mos ruled (mos-1414/1415/1416), over unfixed product files; BUILD.md d3-d5 and ASSERTION-PRESERVATION

This commit is contained in:
jason.woltje committed 2026-10-23 00:50:20 -05:00
1 parent 4992e49097
commit f799e8b155
7 files changed
+596 -4

No files matched your search

@@ -0,0 +1,85 @@
# Row 302 (#1809): assertion preservation
Base 0e30a85fa. `git diff 0e30a85fa -- 'packages/*/tests/*'` on an existing file changes four lines
and nothing else. Each one is ruled by Mos (mos-1414, amended by mos-1415 and mos-1416, relayed in
sage-3206, sage-3209 and sage-3210). No assertion is removed or loosened. Each one now pins the new
code or exit for the same call.
## 1. packages/bus/tests/threads.test.mjs:394 (mos-1414 a)
Old:
```
assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' });
```
New:
```
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
```
:392 (threadIdle's own second refusal, not-holder) and :395 (the pending check) are unchanged.
## 2. packages/harness/tests/runner.test.mjs:771 (mos-1415)
Old:
```
assert.equal(out.code, EXIT.session, out.stderr);
```
New:
```
assert.equal(out.code, 0, out.stderr);
```
out.stderr stays as the message.
## 3. packages/harness/tests/runner.test.mjs:772 (mos-1415)
Old:
```
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${d1.id} \\(vikunja:1/2\\): ok\n[\\s\\S]*runner: session stopped working: not-holder\n`));
```
New:
```
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${d1.id} \\(vikunja:1/2\\): ok\n[\\s\\S]*runner: session released by the launcher\n`));
```
Only the last line of the pattern changes. The landing-turn part is the same, word for word.
## 4. packages/bus/tests/land.test.mjs:464 (mos-1416)
Old:
```
assert.throws(() => w.call('merge-gate', 'message.receive'), /not-holder/);
```
New:
```
assert.throws(() => w.call('merge-gate', 'message.receive'), /launcher-released/);
```
:457, :463 and :466 are unchanged.
## The not-holder grep, rerun on the build (mos-1414)
`git grep -n "not-holder\|session stopped working\|session-invalid"` over packages/cli, seat and harness
tests and scripts/, outside the new files:
- runner.test.mjs:558, `session stopped working: unauthenticated`. Not a release. Stays.
- runner.test.mjs:628 (a comment) and :647, the limit stop. The limit revoke isn't threadIdle or
landRelease, so it stays not-holder and exit 22 (Dewey Q3).
- session.test.mjs:116, a list of end reasons. No call.
In packages/bus/tests, only end-launch, land and threads call threadIdle or landRelease. Their other
not-holder pins: end-launch:198, threads:383-385 and :392, land:457 and :463 are the ops' own refusals.
threads:493 follows assistant.stop. threads:416 releases a2-run as the test's last line. The fix reaches
none of them, so no hit goes to Mos.
+154
View File
@@ -258,3 +258,157 @@ runner.test.mjs:
So under Q1 yes, the Mos package is threads.test:394, land.test:464 and runner.test:771-772. Under Q1 no,
it's threads.test:394 alone. Nothing gets built until Mos rules and Sage gives the go.
## Round d3: as built (after mos-1414/1415/1416 and sage-3220)
Q1 is yes, so the code is `launcher-released` everywhere. The broker set is `#launcherReleased`, not
`#idled`. Every `idle-released` above reads `launcher-released`.
### Correction to d1: message.send doesn't go through `#agent`
d1 said `message.send` and `message.read` both go through `#agent`. `message.read` does. `message.send`
doesn't. So a released run's send isn't refused `launcher-released`. Only `message.receive`, `state`,
`message.read` and `role.claim` answer it, and the bus test asserts exactly those four. That narrows
what Dewey approved in d1, so I'm sending it to Dewey directly (sage-3220).
### Broker (packages/bus/src/broker.mjs)
- `#launcherReleased = new Set()`, keyed `business:run`, next to `#stops`, with a comment.
- threadIdle and landRelease add the key after their release transaction.
- `#agent` refuses `launcher-released` for a run in the set, and `not-holder` otherwise.
- role.claim (D1) refuses `launcher-released` right after the `agent-required` check, before the thread
and role branches. So it covers landRelease's role runs as well as thread runs.
- endLaunch deletes the key after `#ended.add(key)`.
- Q3 needs no code. A limit stop runs `#limitStop` (broker.mjs:4482) before any `#agent` check, and it
fails `not-holder` itself (:4862), so a limit stop after a launcher release still exits 22.
### Runner (packages/harness/src/runner.mjs)
- `const LAUNCHER_RELEASED = "launcher-released"`.
- The shared helper `ended(err, say = true)`. For `launcher-released`, it logs
`runner: session released by the launcher`, sets `released` and leaves the exit code at
`EXIT.stopped`. For the SESSION_GONE codes it keeps the old behaviour (`EXIT.session`, and the log
line unless `say` is false).
- It's used at the landing state check, receive, the turn state check and the reply. The reply site
passes `say = false`, so its logging is the same as before. The three sites that can't see the code
(Q4) carry a comment saying why.
- At exit, the final `role.release` is skipped when `released`, because the launcher already released.
### Tests as built
- `packages/cli/tests/idle-stop-race.test.mjs`. This is the threadIdle forcing case. Inside the first
released run's held threadIdle op, the human's `assistant.stop` revokes the other thread's holder
(N1: no launcher signal can come while the op is held, launcher.mjs:662-663). The op waits for both
runners' verdicts and end events. The idle run logs `session released by the launcher`, ends
`["stopped", 0, null]` before its thread-idle line, and has `exiting 0` and no `session stopped working`.
The control logs `session stopped working: not-holder`, ends `["session-invalid", 22, null]` and
has `exiting 22`. The Store shows `launcher-released` and no `not-holder` refusals for the idle run,
and `not-holder` for the control.
- `packages/bus/tests/launcher-released.test.mjs` (renamed from idle-release.test.mjs). It has three
tests. (1) threadIdle: receive, state, read and role.claim refuse `launcher-released`, role.claim adds
no thread_claims row, the four refusals are logged, nothing is delivered, threadIdle's own second
refusal stays not-holder, and after endLaunch the answer is unauthenticated. (2) The control (N2:
after an idle release): assistant.stop's run answers not-holder and run-revoked, pm's own role.release
answers not-holder, and a new holder for the released thread doesn't change the released run's answer.
(3) landRelease: receive and state refuse `launcher-released`, role.claim refuses and adds no
role_claims row, landRelease's own second refusal stays not-holder, and coder's own role.release
answers not-holder.
- runner.test.mjs:742-772 is the land-relaunch forcing case (mos-1415). There's no cli land-relaunch case.
- The four old lines Mos ruled are in ASSERTION-PRESERVATION.md. The diff on old test files is those four
lines and nothing else.
### Docs
The bus README's threadIdle paragraph names `launcher-released`, the four calls that answer it,
landRelease, and that every other lost claim stays not-holder. The cli README's land-relaunch paragraph
says calls between the release and the signal refuse `launcher-released` and the runner exits 0. The
harness README's exit table row 0 names the launcher release.
### Red first (mos-1415)
There are two commits. The first holds only the tests and the four ruled pin edits. The second holds the
fix (broker.mjs, runner.mjs) and the READMEs. The gate's first stage runs on the tests-only commit, and
these two must fail there:
- idle-stop-race.test.mjs: the idle run ends `["session-invalid", 22, null]`, or its verdict is
`session stopped working: not-holder`, whichever lands first.
- runner.test.mjs, the case at :742: out.code is 22, not 0.
The bus test files and threads.test:394 and land.test:464 fail there too, and the packet records them.
The red run doesn't count as the gate.
### Mutants (each run against the fix commit, restored and checked by sha256 after)
- M1. threadIdle doesn't add the key. Dies: idle-stop-race (end 22), launcher-released.test test 1,
threads.test:394.
- M2. `#agent` ignores the set. Dies: the same as M1, plus launcher-released.test test 3,
land.test:464 and runner.test:771.
- M3. The runner's helper drops the `launcher-released` branch. Dies: idle-stop-race (the run doesn't
exit in the window, so `window.idle` is null) and runner.test:771-772.
- M4. The runner maps `launcher-released` to `EXIT.session`. Dies: idle-stop-race (end 22) and
runner.test:771.
- M5. `#agent` answers `launcher-released` for every lost claim. Dies: the idle-stop-race control
(verdict and end 22), launcher-released.test test 2, and test 3's coder line.
- M6. endLaunch doesn't drop the key. Equivalent (N3), not run.
- M7. role.claim drops the D1 check. Dies: launcher-released.test tests 1 and 3 (the claim succeeds).
- M8. landRelease doesn't add the key. Dies: runner.test:771-772, land.test:464 and
launcher-released.test test 3.
### Run plan (mos-1275 limits, `timeout -k 10`)
1. idle-stop-race.test.mjs alone, 600 s. Then runner.test.mjs whole, 900 s.
2. compactions-launch.test.mjs whole, 600 s.
3. The bus, harness and cli suites whole: bus 1200 s, harness 1200 s, cli 2100 s. 292's cli suite took
630 s.
4. Every scripts/test-*.sh, 900 s each.
5. M1-M5, M7 and M8, each on its named files at the limits above.
TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Every run is slot work and starts only on a
CLEAR from Sage.
## Round d4: Dewey's early static read (dewey-0878, 05:47Z)
C1. The d3 "correction" about message.send was wrong, and d1 was right. `message.send` calls
`#consumeAuthority` (broker.mjs:5506), which calls `#checkAuthority`, which calls `#agent` (:3749).
So a released run's send refuses `launcher-released`, and so does every verb that reaches `#agent`. There
are 29 call sites, including role.release and every authority check. role.claim refuses it before the
claim (D1). I misread the send path in d3, and no code depended on that reading. The runner's reply site
already handles the code. The bus README now says "every call that needs the claim", not a list of four.
Bus test 1 adds a message.send refusal, so the refused list is five `launcher-released`.
N1. Nothing killed a mutant that drops `&& !released` at runner exit (runner.mjs:596). That run calls
role.release, logs `runner: role.release: launcher-released` and still exits 0. idle-stop-race now
asserts the idle run's runner.log has no `runner: role.release:` line.
- M9. Drop `&& !released` at runner.mjs:596. It dies on idle-stop-race (the new doesNotMatch).
N2. idle-stop-race waits for the whole held window (`"revoked" in window`), then asserts
`window.control` is non-null with a message. If the other thread's run isn't holding when A's first
release lands, the test now fails with that message instead of a bare "timed out waiting".
The mutants to run are M1-M5, M7, M8 and M9. These test edits are in a third commit, after the fix.
ad9fa9f16 stays the red-first commit for the two forcing cases mos-1415 names.
## Round d5: red-first reordered (sage-3225, 05:49Z)
Sage pointed out that 8cf2c6d54 changed idle-stop-race.test.mjs and launcher-released.test.mjs after
ad9fa9f16. So a red run on ad9fa9f16 would test older copies than the ones that land. I took the first of
Sage's two options and reordered the commits locally, before any push. The order is now:
1. Tests: the final test files and this packet, over unfixed product files.
2. The fix: broker.mjs, runner.mjs and the three READMEs.
ad9fa9f16, 6e5f782ae and 8cf2c6d54 are superseded and were never pushed. The old tip is kept locally as
gizmoduck/row302-pre-reorder. The fix commit's tree matches 8cf2c6d54's except for this BUILD.md note.
The "third commit" in d4 now lives in these two commits.
The red run uses the tests commit as it stands, with these test blobs (identical in the fix commit):
| Blob | File |
| --- | --- |
| 52343932b231691ba2b4fc117547ea7f6f504704 | packages/bus/tests/land.test.mjs |
| 6c83c9ede41247fd50d40eeac3e8a16a75efacbf | packages/bus/tests/launcher-released.test.mjs |
| 27193a745c4ed8d4936de96c1768ab010a572856 | packages/bus/tests/threads.test.mjs |
| 9dfacfea15a487c4e7056ebae3e311cb514b0467 | packages/cli/tests/idle-stop-race.test.mjs |
| 0792431b60314a17ed74b3df0c5b3748dc3c1286 | packages/harness/tests/runner.test.mjs |
The red run checks these blobs with `git rev-parse HEAD:<path>` before it starts.
+1 -1
View File
@@ -461,7 +461,7 @@ test('landRelease releases the holding run by land-relaunch once it has asked me
{ op: 'release', holder_run: 'merge-gate-run', by: 'land-relaunch', decision: null, reason: null },
);
assert.throws(() => release(), /not-holder/, 'released once');
assert.throws(() => w.call('merge-gate', 'message.receive'), /not-holder/);
assert.throws(() => w.call('merge-gate', 'message.receive'), /launcher-released/);
w.b.endLaunch({ business: 'demo', run: 'merge-gate-run', reason: 'exited' });
assert.throws(() => release(), /run-ended/);
assert.equal(w.store.get("SELECT count(*) AS n FROM deliveries WHERE message=? AND op='delivered'", waiting.id).n, 0, 'still waiting');
@@ -0,0 +1,149 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 302 (#1809, Vikunja 588): threadIdle and landRelease release a run's claim and the launcher
// signals it after. The run's own calls in between answer launcher-released, so its runner exits
// stopped. Every other way a run loses its claim still answers not-holder, and a released run
// doesn't claim again (Dewey's D1 in dewey-0875).
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
function setup(t, roles, definitions) {
const root = mkdtempSync(join(tmpdir(), 'bus-launcher-released-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles,
...(definitions ? { definitions } : {}),
projects: { stack: { root: '/nonexistent' } },
},
};
const b = new Broker({ store, businesses });
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
};
const refused = (run) =>
store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", run).map((e) => JSON.parse(e.body).code);
return { b, store, caps, call, launch, refused };
}
// Two threads of the assistant, each with its own run holding it.
function threads(t) {
const w = setup(
t,
{
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
{ 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
);
const project = w.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = w.call('human', 'thread.create', { project, name: 'alpha' }).id;
const B = w.call('human', 'thread.create', { project, name: 'beta' }).id;
for (const r of ['chief', 'pm']) {
w.launch(r, r);
w.call(r, 'role.claim');
}
w.launch('a', 'assistant', { thread: A });
w.launch('b', 'assistant', { thread: B });
w.call('a', 'role.claim');
w.call('b', 'role.claim');
const idle = (run, thread = A) => w.b.threadIdle({ business: 'demo', thread, run });
return { ...w, A, B, idle };
}
test('row 302 a run threadIdle released answers launcher-released on receive, state, read and send, logs each, and claims nothing again', (t) => {
const { b, store, call, refused, idle, A } = threads(t);
const sent = call('human', 'thread.send', { thread: A, body: 'for A' });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [sent.id]);
call('a', 'message.read', { id: sent.id });
assert.deepEqual(idle('a-run'), { released: true });
const later = call('human', 'thread.send', { thread: A, body: 'later for A' });
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
assert.throws(() => call('a', 'state'), { code: 'launcher-released' });
assert.throws(() => call('a', 'message.read', { id: later.id }), { code: 'launcher-released' });
// message.send needs the claim too, through #consumeAuthority (dewey-0878 C1).
assert.throws(() => call('a', 'message.send', { to: 'chief', body: 'from a released run' }), { code: 'launcher-released' });
// D1: no new claim for the released run, so a later not-holder can't be misreported.
const claims = store.get('SELECT count(*) AS n FROM thread_claims').n;
assert.throws(() => call('a', 'role.claim'), { code: 'launcher-released' });
assert.equal(store.get('SELECT count(*) AS n FROM thread_claims').n, claims);
assert.deepEqual(refused('a-run'), Array(5).fill('launcher-released'));
assert.equal(store.get('SELECT count(*) AS n FROM deliveries WHERE message=?', later.id).n, 0, 'nothing delivered to the released run');
// threadIdle's own refusal is unchanged.
assert.throws(() => idle('a-run'), { code: 'not-holder' });
// After the end the run's caps are gone.
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' });
assert.throws(() => call('a', 'message.receive'), { code: 'unauthenticated' });
});
test('row 302 with a run launcher-released, a run that lost its claim another way still answers not-holder', (t) => {
const { call, launch, idle, A, B } = threads(t);
assert.deepEqual(idle('a-run'), { released: true });
// The human's stop revokes the other thread's holder.
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }).stopped, [{ thread: B, run: 'b-run' }]);
assert.throws(() => call('b', 'message.receive'), { code: 'not-holder' });
assert.throws(() => call('b', 'state'), { code: 'not-holder' });
assert.throws(() => call('b', 'role.claim'), { code: 'run-revoked' });
// A role run that released its own claim.
call('pm', 'role.release');
assert.throws(() => call('pm', 'message.receive'), { code: 'not-holder' });
// A new run holds the released thread; the released run's answer stays its own.
launch('a2', 'assistant', { thread: A });
call('a2', 'role.claim');
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
assert.deepEqual(call('a2', 'message.receive'), []);
});
const MERGE = 'git.merge.protected';
const TASK = 'vikunja:1/2';
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
const w = setup(t, {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
});
for (const role of ['pm', 'coder', 'reviewer', 'merge-gate']) {
w.launch(role, role);
w.call(role, 'role.claim');
}
const commit = 'a'.repeat(40);
w.b.checkPush(w.caps.coder, TASK);
w.b.recordPush(w.caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit, previous: null });
const r = w.call('coder', 'review.request', { task_ref: TASK, commit, to: 'reviewer' });
w.call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
const options = [{ key: 'yes', text: 'Land it' }, { key: 'no', text: 'Not yet' }];
const d = w.call('merge-gate', 'decision.raise', { action: MERGE, target: TASK, question: 'Land?', options, recommendation: 'yes', blocking: false });
w.call('pm', 'decision.resolve', { id: d.id, choice: 'yes' });
const release = () => w.b.landRelease({ business: 'demo', run: 'merge-gate-run', decision: d.id });
for (const m of w.call('merge-gate', 'message.receive')) w.call('merge-gate', 'message.read', { id: m.id });
assert.deepEqual(release(), { released: true });
assert.throws(() => w.call('merge-gate', 'message.receive'), { code: 'launcher-released' });
assert.throws(() => w.call('merge-gate', 'state'), { code: 'launcher-released' });
const claims = w.store.get('SELECT count(*) AS n FROM role_claims').n;
assert.throws(() => w.call('merge-gate', 'role.claim'), { code: 'launcher-released' });
assert.equal(w.store.get('SELECT count(*) AS n FROM role_claims').n, claims);
// landRelease's own refusal is unchanged, and a role run that released itself still answers not-holder.
assert.throws(() => release(), { code: 'not-holder' });
w.call('coder', 'role.release');
assert.throws(() => w.call('coder', 'message.receive'), { code: 'not-holder' });
});
+1 -1
View File
@@ -391,7 +391,7 @@ test('REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing
// Once released, the run can't take the thread's next message; it waits for the next holder.
assert.throws(() => idle('a-run'), { code: 'not-holder' });
const later = call('human', 'thread.send', { thread: A, body: 'later for A' });
assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' });
assert.throws(() => call('a', 'message.receive'), { code: 'launcher-released' });
assert.equal(state()[A].pending, 1);
assert.equal(b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' }).released, false);
assert.throws(() => idle('a-run'), { code: 'run-ended' });
+204
View File
@@ -0,0 +1,204 @@
// Row 302 (#1809, Vikunja 588): the idle stop releases a thread run's claim in the threadIdle op
// and sends SIGTERM only after the op returns. A runner call the broker answers in between used to
// get not-holder, and the run ended session-invalid/22 instead of stopped/0. This test holds the op
// open until a call has landed in that window and the runner has exited, so it doesn't depend on
// timing. The launcher skips a tick while one is running (launcher.mjs, the thread pass), so nothing
// else signals a run while the op is held.
//
// The host is in process, as in thread-launch.test.mjs, with the same fixture.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
function prepare(t) {
const root = tmp(t, "mosaic-idle-race-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 2 });
doc.launch.threadIdleSeconds = 1;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// thread-launch.test.mjs's start(), with threadIdle replaced by the caller.
async function start(t, { f, adapter }, threadIdle) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => threadIdle(runtime, m),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
};
const refusing = (what, fn) => async (m) => {
try {
return await fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: 100,
log: () => {},
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { runtime, launcher, call, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const runnerLog = (f, run) => {
const p = join(f.dataRoot, "launches", "acme", run, "runner.log");
return existsSync(p) ? readFileSync(p, "utf8") : "";
};
const ended = (f, run) => launchLog(f).find((e) => e.event === "end" && e.run === run);
// The runner's own verdict on a refusal: the launcher-release line, or a lost session's.
const verdict = (f, run) => /runner: (session released by the launcher|session stopped working: [a-z-]+)\n/.exec(runnerLog(f, run))?.[1] ?? null;
const settled = async (fn) => {
try {
return await until(fn);
} catch {
return null;
}
};
async function ask(call, thread, body) {
const sent = call("thread.send", { thread, body });
await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
}
test("row 302 REQ-THREAD-2 an idle-released run whose call lands before the SIGTERM ends stopped/0; a run whose claim the human revoked in the same window still ends session-invalid/22", { skip, timeout: 120000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
// The first run the idle stop releases. Inside its held window, the human stops the other
// thread's run, which no launcher pass can signal while the op is held.
let window = null;
let host = null;
const threadIdle = async (runtime, m) => {
const r = runtime.threadIdle(m.record);
if (!r.released || window) return r;
window = { run: m.record.run, thread: m.record.thread };
const other = runtime.threadsState("acme").find((x) => x.thread !== m.record.thread && x.holder);
window.control = other ? { run: other.holder, stopped: host.call("assistant.stop", { instance: "assistant" }).stopped } : null;
// Both runners decide on their own before the op returns and the launcher can signal.
window.idle = await settled(() => verdict(f, window.run) && ended(f, window.run) && verdict(f, window.run));
window.revoked = other ? await settled(() => verdict(f, other.holder) && ended(f, other.holder) && verdict(f, other.holder)) : null;
return r;
};
host = await start(t, prepared, threadIdle);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const B = host.call("thread.create", { project, name: "beta" }).id;
await Promise.all([ask(host.call, A, "for A"), ask(host.call, B, "for B")]);
// The op sets window.revoked last, so this waits for the whole window (dewey-0878 N2).
await until(() => window && "revoked" in window, 60000);
assert.ok(window.control, "the other thread's run held B when A's first release landed");
await until(() => ended(f, window.run) && ended(f, window.control.run), 60000);
const { run, control } = window;
assert.deepEqual(control.stopped.map((s) => s.run), [control.run], "the human's stop took only the other thread's run");
// The released run refused in the window and exited by itself before the op returned.
assert.equal(window.idle, "session released by the launcher", runnerLog(f, run));
const log = launchLog(f);
const end = (r) => log.find((e) => e.event === "end" && e.run === r);
assert.deepEqual([end(run).reason, end(run).exitCode, end(run).signal], ["stopped", 0, null]);
assert.ok(log.indexOf(end(run)) < log.findIndex((e) => e.event === "thread-idle" && e.run === run), "the end came before the idle stop's signal");
assert.doesNotMatch(runnerLog(f, run), /session stopped working/);
assert.match(runnerLog(f, run), /runner: exiting 0\n/);
// The launcher already released the claim, so the runner doesn't release it at exit (dewey-0878 N1).
assert.doesNotMatch(runnerLog(f, run), /runner: role\.release:/);
// The control: a real not-holder in the same window still ends 22, with no signal.
assert.equal(window.revoked, "session stopped working: not-holder", runnerLog(f, control.run));
assert.deepEqual([end(control.run).reason, end(control.run).exitCode, end(control.run).signal], ["session-invalid", 22, null]);
assert.match(runnerLog(f, control.run), /runner: exiting 22\n/);
await host.close();
const store = new Store(f.dataRoot);
t.after(() => store.close());
const codes = (r) => store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", r).map((e) => JSON.parse(e.body).code);
assert.ok(codes(run).includes("launcher-released"), JSON.stringify(codes(run)));
assert.ok(!codes(run).includes("not-holder"), JSON.stringify(codes(run)));
assert.ok(codes(control.run).includes("not-holder"), JSON.stringify(codes(control.run)));
});
+2 -2
View File
@@ -768,8 +768,8 @@ test("a run in a landing turn isn't released for a second held decision until it
);
assert.equal(ctx.store.get("SELECT count(*) AS n FROM deliveries WHERE holder_run='coder-run' AND op='read'").n, 2);
const out = await r.exited;
assert.equal(out.code, EXIT.session, out.stderr);
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${d1.id} \\(vikunja:1/2\\): ok\n[\\s\\S]*runner: session stopped working: not-holder\n`));
assert.equal(out.code, 0, out.stderr);
assert.match(out.stderr, new RegExp(`runner: landing turn 1 for decision ${d1.id} \\(vikunja:1/2\\): ok\n[\\s\\S]*runner: session released by the launcher\n`));
});
test("a malformed landing exits 2 before the claim, with no turn", async (t) => {