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:
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.
|
||||
@@ -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.
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user