harness-everything 0.47.2 → 0.47.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.47.2",
13
+ "version": "0.47.3",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.47.2",
4
+ "version": "0.47.3",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
@@ -118,6 +118,8 @@ jobs:
118
118
  node-version: 24
119
119
  - name: Install dependencies for regression suite
120
120
  run: npm ci
121
+ - name: Verify OpenCode async process fixtures before the full suite
122
+ run: node ci/mechanism-338-opencode-async.test.js
121
123
  - name: Run hermetic self-regression suite
122
124
  run: npm test
123
125
  - name: Run focused mutation checks
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.47.3](https://github.com/dyphn1/Harness-everything/compare/v0.47.2...v0.47.3) (2026-10-31)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **docs:** preserve exact file identities in derived comparisons ([8bc3ddb](https://github.com/dyphn1/Harness-everything/commit/8bc3ddbac38eaffa7495e70922dc1240f9aa63eb))
10
+ * **docs:** repin shipped verifier after exact file identity fix ([c24d0f1](https://github.com/dyphn1/Harness-everything/commit/c24d0f100ce392edd02275aa06b58f141ade6cdd))
11
+ * **opencode:** invalidate idle waiters on session reset ([41b2663](https://github.com/dyphn1/Harness-everything/commit/41b2663f66edbe77188c3ca9fa3b0add6f7ab76d))
12
+ * **opencode:** stream verification asynchronously within a shared budget ([1470247](https://github.com/dyphn1/Harness-everything/commit/1470247249548ec6ccf2acaf97463824e0268be4)), closes [#338](https://github.com/dyphn1/Harness-everything/issues/338)
13
+
5
14
  ## [0.47.2](https://github.com/dyphn1/Harness-everything/compare/v0.47.1...v0.47.2) (2026-10-31)
6
15
 
7
16
  ### Bug Fixes
@@ -0,0 +1,24 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const vm = require('vm');
4
+ const acorn = require('acorn');
5
+ const childProcess = require('child_process');
6
+
7
+ // Load the private runner without adding exports to the portable plugin's
8
+ // host-discovery surface. Parsing preserves async declarations and boundaries.
9
+ module.exports = function loadRunner(root, overrides = {}) {
10
+ const source = fs.readFileSync(path.join(root, 'opencode-plugin/index.mjs'), 'utf8');
11
+ const names = new Set(['FAILURE_MARKER_PATTERNS', 'CONTEXT_MARKER_PATTERNS',
12
+ 'VERIFY_TIMEOUT_ENV', 'DEFAULT_VERIFY_TIMEOUT_MS', 'VERIFY_OUTPUT_LIMIT',
13
+ 'VERIFY_LINE_LIMIT', 'VERIFY_KILL_GRACE_MS', 'normalizeFailureLine',
14
+ 'firstFailureMarker', 'buildFailureSignature', 'getAvailableScripts',
15
+ 'verifyTimeoutMs', 'createVerificationCapture', 'terminateVerificationChild',
16
+ 'runVerificationCommand', 'runVerification']);
17
+ const ast = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
18
+ const declarations = ast.body.filter(node => names.has(node.id?.name || node.declarations?.[0]?.id?.name));
19
+ const context = { process, Buffer, setTimeout, clearTimeout, setInterval, clearInterval,
20
+ createHash: require('crypto').createHash, spawn: childProcess.spawn,
21
+ existsSync: fs.existsSync, readFileSync: fs.readFileSync, join: path.join, ...overrides };
22
+ return vm.runInNewContext(declarations.map(node => source.slice(node.start, node.end)).join('\n')
23
+ + '\n;({ runVerification, runVerificationCommand, createVerificationCapture, buildFailureSignature, verifyTimeoutMs })', context);
24
+ };
@@ -77,6 +77,13 @@ helper.check(
77
77
 
78
78
  const push = (event) => eventQueue.push(event);
79
79
  const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
80
+ const waitFor = async (predicate) => {
81
+ const deadline = Date.now() + 5000;
82
+ while (!predicate()) {
83
+ if (Date.now() > deadline) throw new Error('V2 async verification did not settle');
84
+ await new Promise(resolve => setTimeout(resolve, 20));
85
+ }
86
+ };
80
87
 
81
88
  const mod = await import(pathToFileURL(path.join(helper.root, 'opencode-plugin', 'index.mjs')).href);
82
89
  const definition = mod.default;
@@ -113,7 +120,7 @@ helper.check(
113
120
  for (let i = 1; i <= 3; i++) {
114
121
  await after({ tool: 'edit', sessionID: 's1', id: `c${i}`, input: {} });
115
122
  push({ type: 'session.idle', sessionID: 's1' });
116
- await settle();
123
+ await waitFor(() => prompts.length === i);
117
124
  }
118
125
 
119
126
  helper.check(
@@ -228,7 +228,7 @@ function caseD4(leg) {
228
228
  fs.writeFileSync(candidate, differing);
229
229
  const before = sha256(path.join(ws, 'docs', 'index.md'));
230
230
  const result = runCompare(leg.dir, { workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok' });
231
- same(result.status, 1, 'exit');
231
+ same(result.status, 1, `exit; stdout=${truncate(result.stdout)}; stderr=${truncate(result.stderr)}`);
232
232
  if (!JSON.stringify(jsonOut(result)).includes('stale')) throw new Error(`result state is not stale: ${truncate(result.stdout)}`);
233
233
  same(sha256(path.join(ws, 'docs', 'index.md')), before, 'target preserved');
234
234
  }
@@ -364,6 +364,46 @@ function caseD13(leg) {
364
364
  }
365
365
  assertPerLeg('case-d13:crlf-candidate-not-normalized', leg => caseD13(leg));
366
366
 
367
+ // Windows file IDs can exceed Number's exact integer range. Exercise the real
368
+ // CLI with deterministic stat identities so runner allocation cannot hide it.
369
+ assertPerLeg('case-d13b:64-bit-file-identities-remain-distinct', leg => {
370
+ const { ws } = viewWorkspace();
371
+ writeTarget(ws);
372
+ const dir = candidateDir();
373
+ const candidate = path.join(dir, 'candidate.md');
374
+ const target = path.join(ws, 'docs', 'index.md');
375
+ fs.writeFileSync(candidate, 'different bytes\n');
376
+ const large = 2n ** 60n;
377
+ same(Number(large), Number(large + 1n), 'fixture must collide as Number');
378
+ for (const scenario of ['distinct-inode', 'distinct-device', 'alias']) {
379
+ const preload = path.join(dir, `${scenario}.cjs`);
380
+ fs.writeFileSync(preload, `
381
+ const fs = require('fs');
382
+ const path = require('path');
383
+ const original = fs.statSync;
384
+ fs.statSync = (file, options) => {
385
+ const result = original(file, options);
386
+ const name = path.resolve(String(file));
387
+ if (name === ${JSON.stringify(candidate)} || name === ${JSON.stringify(target)}) {
388
+ const candidate = name === ${JSON.stringify(candidate)};
389
+ const convert = value => options && options.bigint ? value : Number(value);
390
+ result.ino = convert(${large}n + (candidate && ${JSON.stringify(scenario)} === 'distinct-inode' ? 1n : 0n));
391
+ result.dev = convert(${large}n + (candidate && ${JSON.stringify(scenario)} === 'distinct-device' ? 1n : 0n));
392
+ }
393
+ return result;
394
+ };
395
+ `);
396
+ const result = runCompare(leg.dir, {
397
+ workspace: ws, config: CONFIG, view: 'docs/index.md', candidate, status: 'ok',
398
+ extra: { env: { ...process.env, NODE_OPTIONS: `--require "${preload.replace(/\\/g, '/')}"` } }
399
+ });
400
+ const alias = scenario === 'alias';
401
+ same(result.status, alias ? 2 : 1, `${scenario} exit`);
402
+ same(jsonOut(result).result, alias ? 'comparison-unavailable' : 'stale', `${scenario} verdict`);
403
+ if (alias) same(jsonOut(result).reason, 'candidate aliases the target', 'alias protection retained');
404
+ }
405
+ });
406
+
367
407
  // D14: every compare-derived lane above (plus D1) printed exactly one JSON object on stdout.
368
408
  {
369
409
  const id = 'case-d14:stdout-single-json-object';
@@ -487,7 +527,7 @@ function mp3Apply() {
487
527
  const dir = scriptCopy('f3 derived mp3 ');
488
528
  // The guard's only decision line — elsewhere in the mutant the script stays intact, so the
489
529
  // aliased run proceeds to a real byte comparison (exit 0, equal) instead of crashing.
490
- applyMutation(dir, 'return x.ino > 0 && x.ino === y.ino && x.dev === y.dev;', 'return false;', 'mp3 sameFile identity guard');
530
+ applyMutation(dir, 'return x.ino > 0n && x.ino === y.ino && x.dev === y.dev;', 'return false;', 'mp3 sameFile identity guard');
491
531
  return dir;
492
532
  }
493
533
 
@@ -0,0 +1,229 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { execFileSync } = require('child_process');
5
+ const { pathToFileURL } = require('url');
6
+ const helper = require('./test-helper');
7
+
8
+ (async () => {
9
+ const workspace = helper.tempDir('.mechanism-test-338-async');
10
+ fs.mkdirSync(workspace, { recursive: true });
11
+ execFileSync('git', ['init', '-q'], { cwd: workspace });
12
+ const prompts = [];
13
+ const client = { session: { prompt: async value => { prompts.push(value.body.parts[0].text); } } };
14
+ const mod = await import(pathToFileURL(path.join(helper.root, 'opencode-plugin/index.mjs')).href);
15
+ const { HarnessEnforcement } = mod;
16
+ const hooks = await HarnessEnforcement({ client, directory: workspace });
17
+ const runner = require('./lib/opencode-verification-loader')(helper.root);
18
+ const configure = scripts => fs.writeFileSync(path.join(workspace, 'package.json'), JSON.stringify({ name: 'async-fixture', scripts }));
19
+ // npm uses cmd.exe on Windows: JSON string escaping is not shell quoting.
20
+ // Keep source out of shell commands. setup-node places this Node on PATH;
21
+ // a quoted absolute executable adds another cmd.exe /s quoting boundary.
22
+ const nodeFile = file => `node "${file}"`;
23
+ let fixtureID = 0;
24
+ const node = program => {
25
+ const file = `program-${++fixtureID}.cjs`;
26
+ fs.writeFileSync(path.join(workspace, file), `require('fs').writeFileSync('${file}.started', 'yes');\n${program}`);
27
+ return nodeFile(file);
28
+ };
29
+ let calls = 0;
30
+ const edit = sessionID => hooks['tool.execute.after']({ tool: 'edit', sessionID, callID: `338-${++calls}`, args: { filePath: 'code.js' } }, {});
31
+ const idle = sessionID => hooks.event({ event: { type: 'session.idle', properties: { sessionID } } });
32
+ const waitFor = async predicate => {
33
+ const deadline = Date.now()+5000;
34
+ while (!predicate()) {
35
+ if (Date.now() > deadline) throw new Error('fixture did not become ready');
36
+ await new Promise(resolve => setTimeout(resolve, 20));
37
+ }
38
+ };
39
+
40
+ const slowCommand = node('setTimeout(() => {}, 600)');
41
+ configure({ test: slowCommand });
42
+ await edit('heartbeat');
43
+ let heartbeats = 0;
44
+ const ticker = setInterval(() => { heartbeats++; }, 20);
45
+ await idle('heartbeat');
46
+ clearInterval(ticker);
47
+ helper.check('338. slow verification leaves the plugin event loop responsive', heartbeats >= 3 && fs.existsSync(path.join(workspace, 'program-1.cjs.started')), `heartbeats=${heartbeats}; ${prompts.at(-1) || ''}`);
48
+
49
+ const largeBytes = 320 * 65536;
50
+ const largeCommand = node("for(let i=0;i<320;i++)process.stdout.write('x'.repeat(65536)); process.stdout.write('',()=>require('fs').writeFileSync('large-output-bytes.txt',String(320*65536))); console.error('AssertionError: large-output-check');process.exitCode=1");
51
+ configure({ test: largeCommand });
52
+ await edit('large-output');
53
+ await idle('large-output');
54
+ helper.check('338. output beyond the former 16 MiB cap preserves a real test verdict', fs.existsSync(path.join(workspace, 'large-output-bytes.txt')) && fs.readFileSync(path.join(workspace, 'large-output-bytes.txt'), 'utf8') === String(largeBytes) && /verification failed/.test(prompts.at(-1)) && /large-output-check/.test(prompts.at(-1)), prompts.at(-1));
55
+ // A producer receipt proves the npm script ran; an independent expected
56
+ // stream digest additionally proves the runner consumed every emitted byte.
57
+ const largeResult = await runner.runVerificationCommand(workspace, largeCommand, 10000);
58
+ const stdoutDigest = crypto.createHash('sha256').update('x'.repeat(largeBytes)).digest('hex');
59
+ const stderrDigest = crypto.createHash('sha256').update('AssertionError: large-output-check').digest('hex');
60
+ const expectedDigest = crypto.createHash('sha256').update(`stdout:${stdoutDigest}\nstderr:${stderrDigest}`).digest('hex').slice(0, 16);
61
+ helper.check('338. the complete >16 MiB producer stream reaches the runner',
62
+ largeBytes > 16*1024*1024 && largeResult.outcome === 'test_failure' && largeResult.exitCode === 1
63
+ && largeResult.failureMarker === 'AssertionError: large-output-check' && largeResult.evidenceDigest === expectedDigest,
64
+ JSON.stringify(largeResult));
65
+ helper.check('338. streamed diagnostics stay bounded', prompts.at(-1).length < 2600, `prompt bytes=${prompts.at(-1).length}`);
66
+
67
+ configure({ test: node("setTimeout(() => require('fs').writeFileSync('budget-test-finished.txt','yes'), 2200)"), lint: node("require('fs').writeFileSync('budget-lint-started.txt','yes');setTimeout(() => {}, 2200)"), build: node("require('fs').writeFileSync('must-not-build.txt','ran')") });
68
+ process.env.HARNESS_VERIFY_TIMEOUT_MS = '4000';
69
+ await edit('total-budget');
70
+ const started = Date.now();
71
+ const previous = prompts.length;
72
+ await idle('total-budget');
73
+ delete process.env.HARNESS_VERIFY_TIMEOUT_MS;
74
+ helper.check('338. all scripts share one verification budget', prompts.length === previous+1 && /timed out/.test(prompts.at(-1)) && fs.existsSync(path.join(workspace, 'budget-test-finished.txt')) && fs.existsSync(path.join(workspace, 'budget-lint-started.txt')), `duration=${Date.now()-started}; ${prompts.at(-1)}`);
75
+ helper.check('338. an expired sequence never launches later scripts', !fs.existsSync(path.join(workspace, 'must-not-build.txt')));
76
+
77
+ const missing = await runner.runVerificationCommand(path.join(workspace, 'missing-cwd'), 'npm test', 2000);
78
+ helper.check('338. spawn ENOENT is an execution error with no verdict', missing.outcome === 'execution_error' && missing.exitCode === null && /ENOENT/.test(missing.error), JSON.stringify(missing));
79
+ const pass = await runner.runVerificationCommand(workspace, node('process.exit(0)'), 2000);
80
+ helper.check('338. actual exit zero is pass', pass.success && pass.outcome === 'pass' && pass.exitCode === 0, JSON.stringify(pass));
81
+ const failed = await runner.runVerificationCommand(workspace, node("console.log('FAIL parser.test.js');console.error('AssertionError: parse alpha');process.exitCode=2"), 2000);
82
+ helper.check('338. nonzero exit preserves stderr marker and stdout suite context', failed.outcome === 'test_failure' && failed.exitCode === 2 && failed.failureMarker === 'FAIL parser.test.js: AssertionError: parse alpha' && /parse alpha/.test(failed.error), JSON.stringify(failed));
83
+
84
+ const signature = (out, err, chunks = 10000) => {
85
+ const stdout = runner.createVerificationCapture(), stderr = runner.createVerificationCapture();
86
+ for (let i=0; i<out.length; i+=chunks) stdout.write(out.slice(i, i+chunks));
87
+ for (let i=0; i<err.length; i+=chunks) stderr.write(err.slice(i, i+chunks));
88
+ const a = stdout.finish(), b = stderr.finish();
89
+ const marker = a.unsafeIdentity || b.unsafeIdentity ? null : a.marker
90
+ ? `${a.markerContext ? `${a.markerContext}: ` : ''}${a.marker}`
91
+ : b.marker ? `${b.markerContext || a.context ? `${b.markerContext || a.context}: ` : ''}${b.marker}` : null;
92
+ return runner.buildFailureSignature({ command: 'npm test', outcome: 'test_failure', exitCode: 1,
93
+ failureMarker: marker, evidenceDigest: crypto.createHash('sha256').update(`stdout:${a.digest}\nstderr:${b.digest}`).digest('hex').slice(0,16) });
94
+ };
95
+ const longPrefix = `not ok 1 - ${'same'.repeat(1500)}`;
96
+ helper.check('338. truncated test identities use complete stream digests', signature(`${longPrefix} ALPHA\n`, '') !== signature(`${longPrefix} BETA\n`, ''));
97
+ const observedLong = [];
98
+ for (const name of ['ALPHA', 'BETA']) {
99
+ fs.writeFileSync(path.join(workspace, 'long-marker.js'), `console.log(${JSON.stringify(longPrefix+name)});process.exitCode=1`);
100
+ observedLong.push(await runner.runVerificationCommand(workspace, nodeFile('long-marker.js'), 2000));
101
+ }
102
+ helper.check('338. real long-marker child verdicts cannot collide after truncation', observedLong.every(value => value.outcome === 'test_failure' && value.failureMarker === null) && runner.buildFailureSignature(observedLong[0]) !== runner.buildFailureSignature(observedLong[1]));
103
+ helper.check('338. truncated suite contexts cannot merge distinct failures', signature(`FAIL ${longPrefix}ALPHA\n`, 'AssertionError: same assertion\n') !== signature(`FAIL ${longPrefix}BETA\n`, 'AssertionError: same assertion\n'));
104
+ const stableFailures = [];
105
+ for (const banner of ['ALPHA', 'BETA']) {
106
+ fs.writeFileSync(path.join(workspace, 'volatile-banner.js'), `process.stdout.write('${banner}'+'x'.repeat(17*1024*1024));console.error('AssertionError: stable failure');process.exitCode=1`);
107
+ stableFailures.push(await runner.runVerificationCommand(workspace, nodeFile('volatile-banner.js'), 3000));
108
+ }
109
+ helper.check('338. huge volatile non-marker banners preserve a later stable failure identity', stableFailures.every(value => value.failureMarker === 'AssertionError: stable failure') && runner.buildFailureSignature(stableFailures[0]) === runner.buildFailureSignature(stableFailures[1]));
110
+ const context = 'FAIL parser.test.js\n ● Console\n console.log\n ● payload\n';
111
+ const stderr = ' ● parser › alpha (1.2ms)\n';
112
+ helper.check('338. chunk boundaries preserve marker and stream context', signature(context, stderr, 1) === signature(context, stderr, 10000) && /FAIL parser.test.js: ● parser › alpha/.test(signature(context, stderr, 1)));
113
+ helper.check('338. markerless whitespace normalization survives chunk boundaries', signature(' progress \n still running ', ' last line ', 1) === signature('progress still running', 'last line', 10000));
114
+
115
+ // Both shell and nested child remain in the inherited process group.
116
+ fs.writeFileSync(path.join(workspace, 'grandchild.js'), "const fs=require('fs');process.on('SIGTERM',()=>{});setInterval(()=>fs.appendFileSync('heartbeat.txt','x'),20)");
117
+ fs.writeFileSync(path.join(workspace, 'worker.js'), "require('child_process').spawn(process.execPath,['grandchild.js'],{stdio:'inherit'});setInterval(()=>{},1000)");
118
+ const cleanupResult = await runner.runVerificationCommand(workspace, nodeFile('worker.js'), 700);
119
+ const heartbeat = path.join(workspace, 'heartbeat.txt');
120
+ const size = fs.existsSync(heartbeat) ? fs.statSync(heartbeat).size : -1;
121
+ await new Promise(resolve => setTimeout(resolve, 200));
122
+ helper.check('338. timeout stops nested SIGTERM-ignoring child output', cleanupResult.outcome === 'timeout' && size > 0 && fs.statSync(heartbeat).size === size, JSON.stringify({ cleanupResult, size }));
123
+ if (process.platform !== 'win32') {
124
+ const externalRunner = require('./lib/opencode-verification-loader')(helper.root, { spawn: (command, options) => {
125
+ const child = require('child_process').spawn(command, options);
126
+ setTimeout(() => { try { child.kill('SIGTERM'); } catch {} }, 300);
127
+ return child;
128
+ } });
129
+ const signalled = await externalRunner.runVerificationCommand(workspace, nodeFile('worker.js'), 3000);
130
+ const afterSignal = fs.statSync(heartbeat).size;
131
+ await new Promise(resolve => setTimeout(resolve, 200));
132
+ helper.check('338. early external signals are execution errors and also clean the group', signalled.outcome === 'execution_error' && signalled.signal === 'SIGTERM' && !signalled.timedOut && fs.statSync(heartbeat).size === afterSignal, JSON.stringify(signalled));
133
+ }
134
+
135
+ fs.writeFileSync(path.join(workspace, 'ready.js'), "require('fs').writeFileSync('ready.txt','yes');setInterval(()=>{},1000)");
136
+ configure({ test: nodeFile('ready.js') });
137
+ await edit('edited-during-run');
138
+ const beforeConcurrent = prompts.length;
139
+ const active = idle('edited-during-run');
140
+ await waitFor(() => fs.existsSync(path.join(workspace, 'ready.txt')));
141
+ await Promise.all([idle('edited-during-run'), edit('edited-during-run')]);
142
+ await active;
143
+ const real = fs.realpathSync(workspace);
144
+ const slug = path.basename(real).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'workspace';
145
+ const hash = crypto.createHash('sha1').update(process.platform === 'win32' ? real.toLowerCase() : real).digest('hex').slice(0,12);
146
+ const sessions = path.join(process.env.HARNESS_STATE_HOME, 'workspaces', `${slug}-${hash}`, 'state/sessions');
147
+ const editedState = JSON.parse(fs.readFileSync(path.join(sessions, 'edited-during-run/edit-state.json')));
148
+ helper.check('338. a concurrent edit cancels stale verification without duplicate follow-ups', editedState.verificationPending === true && editedState.editsSinceVerification === 2 && prompts.length === beforeConcurrent, JSON.stringify(editedState));
149
+ configure({ test: node('process.exit(0)') });
150
+ await idle('edited-during-run');
151
+ helper.check('338. a fresh idle verifies the newer edits', JSON.parse(fs.readFileSync(path.join(sessions, 'edited-during-run/edit-state.json'))).verificationPending === false);
152
+
153
+ fs.unlinkSync(path.join(workspace, 'ready.txt'));
154
+ configure({ test: nodeFile('ready.js') });
155
+ await edit('deleted-during-run');
156
+ const deleting = idle('deleted-during-run');
157
+ await waitFor(() => fs.existsSync(path.join(workspace, 'ready.txt')));
158
+ await hooks.event({ event: { type: 'session.deleted', properties: { sessionID: 'deleted-during-run' } } });
159
+ await deleting;
160
+ helper.check('338. session deletion aborts the runner without resurrecting state', !fs.existsSync(path.join(sessions, 'deleted-during-run')) && prompts.length === beforeConcurrent);
161
+
162
+ // The V2 adapter must keep consuming reset/delete events while idle
163
+ // verification is pending, and its disposer must await child cleanup.
164
+ const queue = [], registrations = new Map();
165
+ const dispose = await mod.default.setup({
166
+ location: { directory: workspace },
167
+ tool: { hook: async (name, callback) => { registrations.set(name, callback); } },
168
+ session: { prompt: async value => { prompts.push(value.text); } },
169
+ event: { subscribe: async function* ({ signal }) {
170
+ while (!signal.aborted) {
171
+ if (queue.length) yield queue.shift();
172
+ else await new Promise(resolve => setTimeout(resolve, 5));
173
+ }
174
+ } },
175
+ });
176
+ fs.writeFileSync(path.join(workspace, 'v2.js'), "const fs=require('fs');fs.writeFileSync('v2-ready.txt','yes');setInterval(()=>fs.appendFileSync('v2-heartbeat.txt','x'),20)");
177
+ configure({ test: nodeFile('v2.js') });
178
+ const v2Edit = sessionID => registrations.get('execute.after')({ tool: 'edit', sessionID, id: `338-v2-${++calls}`, input: { filePath: 'code.js' } });
179
+ const v2Ready = path.join(workspace, 'v2-ready.txt');
180
+ const v2Heartbeat = path.join(workspace, 'v2-heartbeat.txt');
181
+ for (const type of ['session.deleted', 'session.created']) {
182
+ const sessionID = `v2-${type}`;
183
+ if (fs.existsSync(v2Ready)) fs.unlinkSync(v2Ready);
184
+ await v2Edit(sessionID);
185
+ queue.push({ type: 'session.idle', sessionID });
186
+ await waitFor(() => fs.existsSync(v2Ready) && fs.existsSync(v2Heartbeat));
187
+ queue.push({ type, sessionID });
188
+ await waitFor(() => !fs.existsSync(path.join(sessions, sessionID)));
189
+ await new Promise(resolve => setTimeout(resolve, 250));
190
+ const stopped = fs.statSync(v2Heartbeat).size;
191
+ await new Promise(resolve => setTimeout(resolve, 100));
192
+ helper.check(`338. V2 consumes ${type} during verification and stops its child`, fs.statSync(v2Heartbeat).size === stopped && prompts.length === beforeConcurrent);
193
+ }
194
+ fs.unlinkSync(v2Ready);
195
+ await v2Edit('v2-disposed');
196
+ queue.push({ type: 'session.idle', sessionID: 'v2-disposed' });
197
+ await waitFor(() => fs.existsSync(v2Ready));
198
+ await dispose();
199
+ const stopped = fs.statSync(v2Heartbeat).size;
200
+ await new Promise(resolve => setTimeout(resolve, 150));
201
+ helper.check('338. V2 disposal waits for active child cleanup without a stale follow-up', fs.statSync(v2Heartbeat).size === stopped && prompts.length === beforeConcurrent);
202
+ // A new idle can arrive while an edit-cancelled child is still being reaped.
203
+ // That idle must verify the newer revision after cleanup, rather than vanish.
204
+ fs.unlinkSync(path.join(workspace, 'ready.txt'));
205
+ configure({ test: nodeFile('ready.js') });
206
+ await edit('idle-during-cleanup');
207
+ const cancellingIdle = idle('idle-during-cleanup');
208
+ await waitFor(() => fs.existsSync(path.join(workspace, 'ready.txt')));
209
+ await edit('idle-during-cleanup');
210
+ configure({ test: node('process.exit(0)') });
211
+ await Promise.all([cancellingIdle, idle('idle-during-cleanup')]);
212
+ const afterCleanup = JSON.parse(fs.readFileSync(path.join(sessions, 'idle-during-cleanup/edit-state.json')));
213
+ helper.check('338. idle arriving during cancellation verifies the latest edit after cleanup',
214
+ afterCleanup.verificationPending === false && afterCleanup.editsSinceVerification === 0 && prompts.length === beforeConcurrent,
215
+ JSON.stringify(afterCleanup));
216
+
217
+ fs.unlinkSync(path.join(workspace, 'ready.txt'));
218
+ configure({ test: nodeFile('ready.js') });
219
+ await edit('deleted-while-waiting');
220
+ const runningBeforeDelete = idle('deleted-while-waiting');
221
+ await waitFor(() => fs.existsSync(path.join(workspace, 'ready.txt')));
222
+ await edit('deleted-while-waiting');
223
+ const waitingDuringDelete = idle('deleted-while-waiting');
224
+ await hooks.event({ event: { type: 'session.deleted', properties: { sessionID: 'deleted-while-waiting' } } });
225
+ await Promise.all([runningBeforeDelete, waitingDuringDelete]);
226
+ helper.check('338. deletion invalidates an idle waiting for cancelled-child cleanup',
227
+ !fs.existsSync(path.join(sessions, 'deleted-while-waiting')) && prompts.length === beforeConcurrent);
228
+ helper.finish();
229
+ })().catch(error => { console.error(error); process.exit(1); });
@@ -153,6 +153,14 @@ async function startPlugin(workspace) {
153
153
  return { registered, stream, dispose };
154
154
  }
155
155
 
156
+ async function waitForIdleConsumer(predicate) {
157
+ const deadline = Date.now() + 5000;
158
+ while (!predicate()) {
159
+ assert.ok(Date.now() < deadline, 'asynchronous idle consumer did not complete');
160
+ await new Promise(resolve => setTimeout(resolve, 20));
161
+ }
162
+ }
163
+
156
164
  function canonicalJson(value) {
157
165
  if (Array.isArray(value)) return value.map(canonicalJson);
158
166
  if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]));
@@ -235,6 +243,7 @@ test('OpenCode capture → idle Stop whole path is refused without live qualific
235
243
  }
236
244
 
237
245
  await plugin.stream.dispatch({ type: 'session.idle', sessionID: sessionId });
246
+ await waitForIdleConsumer(() => assessmentSessions.filter(id => id === sessionId).length === 1);
238
247
  const reportPath = path.join(sessionDir, 'doc-reconciliation-pilot.json');
239
248
  const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) : null;
240
249
  if (assessmentSessions.filter(id => id === sessionId).length !== 1) {
@@ -245,7 +254,7 @@ test('OpenCode capture → idle Stop whole path is refused without live qualific
245
254
  }
246
255
  assert.deepEqual(findings, []);
247
256
  } finally {
248
- if (dispose) dispose();
257
+ if (dispose) await dispose();
249
258
  if (originalAssess) require(PILOT).assess = originalAssess;
250
259
  fixture.cleanup();
251
260
  restoreEnv();
@@ -277,6 +286,7 @@ test('session idle dispatches the real consumer once across every existing early
277
286
  const { sessionDir } = seedWorkflow(fixture, sessionId);
278
287
  writeEditState(sessionDir, state);
279
288
  await plugin.stream.dispatch({ type: 'session.idle', sessionID: sessionId });
289
+ await waitForIdleConsumer(() => assessmentSessions.filter(id => id === sessionId).length === 1);
280
290
  const reportPath = path.join(sessionDir, 'doc-reconciliation-pilot.json');
281
291
  const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) : null;
282
292
  if (assessmentSessions.filter(id => id === sessionId).length !== 1) {
@@ -288,12 +298,13 @@ test('session idle dispatches the real consumer once across every existing early
288
298
  }
289
299
  const cleanupSession = 'idle-no-verification-pending';
290
300
  await plugin.stream.dispatch({ type: 'session.idle', sessionID: cleanupSession });
301
+ await waitForIdleConsumer(() => assessmentSessions.filter(id => id === cleanupSession).length === 2);
291
302
  if (assessmentSessions.filter(id => id === cleanupSession).length !== 2) {
292
303
  findings.push(`finally did not release the per-session idle guard for a second event: ${assessmentSessions.filter(id => id === cleanupSession).length} assessments`);
293
304
  }
294
305
  assert.deepEqual(findings, []);
295
306
  } finally {
296
- if (dispose) dispose();
307
+ if (dispose) await dispose();
297
308
  pilot.assess = originalAssess;
298
309
  fixture.cleanup();
299
310
  restoreEnv();
@@ -332,7 +343,7 @@ test('V1/no-status, pilot-OFF, no-workflow, and Claude host controls stay fail-c
332
343
  assert.equal(claudeContext.status, 'unavailable', 'OpenCode must not enter through Claude provider semantics');
333
344
  assert.ok(claudeContext.diagnostics.some(item => item.code === 'unsupported-host'));
334
345
  } finally {
335
- if (dispose) dispose();
346
+ if (dispose) await dispose();
336
347
  fixture.cleanup();
337
348
  restoreEnv();
338
349
  }