Files
stack/packages/harness/tests/launch-backoff.test.mjs
jason.woltje 4048e0f60c fix(bus,harness): stop INFO echo replies and duplicate continuation notices (#1847, #1848)
Expose the parent message class on agent delivery so the runner can stop automatic replies to answers while preserving requests and ordinary relays. Scope continuation retry versions to the target, suppress notices during processing halts and unclaimed launches, and clarify the instance-running refusal.

The bus suite excludes project-files.test.mjs because its 1 MiB socket test timed out on both the candidate and the untouched 1186cf7b1 base.
2026-11-06 14:14:53 -06:00

111 lines
6.0 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { connect } from 'node:net';
import * as runner from '../src/runner.mjs';
import { callTool } from '../src/tools.mjs';
const turn = () => new Promise(resolve => setImmediate(resolve));
test('a running instance is a wait: repeated launch calls are delayed, independently per instance', async t => {
const guard = runner.launchBackoff ?? (send => send);
const dir = fs.mkdtempSync(join(tmpdir(),'backoff797-'));
t.after(() => fs.rmSync(dir, {recursive:true,force:true}));
const calls=[], delays=[]; let now=0;
const launch = guard(async ({instance}) => {
calls.push(instance);
if (instance === 'pm') throw Object.assign(new Error('instance-running'), {code:'instance-running'});
return {run:'other-run'};
}, {now:()=>now, wait:ms=>new Promise(resolve=>delays.push({ms,resolve}))});
const path=join(dir,'tools.sock');
const server=await runner.toolServer({path,tools:[{name:'launch',verb:'launch'}],client:{},local:{launch}});
t.after(()=>new Promise(resolve=>server.close(resolve)));
await assert.rejects(callTool(path,'launch',{instance:'pm'}),/instance-running.*already running.*waiting messages.*claims.*must not launch.*relay/i);
const second=callTool(path,'launch',{instance:'pm'});
for(let n=0;n<100 && !delays.length;n++) await new Promise(resolve=>setTimeout(resolve,2));
assert.deepEqual(calls,['pm']);
assert.equal(delays.length,1); assert.ok(delays[0].ms >= 30000);
assert.deepEqual(await callTool(path,'launch',{instance:'coder'}),{run:'other-run'});
now += delays[0].ms; delays[0].resolve();
await assert.rejects(second,/instance-running.*already running.*waiting messages.*claims.*must not launch.*relay/i);
assert.deepEqual(calls,['pm','coder','pm']);
});
test('a success clears the instance-running delay', async () => {
const guard = runner.launchBackoff ?? (send => send);
let now=0, calls=0, refusal=true; const waits=[];
const launch=guard(async () => {calls++; if(refusal) throw Object.assign(new Error('instance-running'),{code:'instance-running'}); return {run:'r1'};},
{now:()=>now,wait:async ms=>{waits.push(ms);now+=ms;}});
await assert.rejects(launch({instance:'coder'}),{code:'instance-running'});
refusal=false;
assert.deepEqual(await launch({instance:'coder'}),{run:'r1'});
assert.deepEqual(waits,[30000]);
assert.deepEqual(await launch({instance:'coder'}),{run:'r1'});
assert.deepEqual(waits,[30000]); assert.equal(calls,3);
});
test('no-route fails fast and starts no cooldown after the route is set', async () => {
let calls=0, routed=false; const waits=[];
const launch=runner.launchBackoff(async () => {
calls++;
if (!routed) throw Object.assign(new Error('no-route'), {code:'no-route'});
return {run:'r1'};
}, {now:()=>0, wait:async ms=>waits.push(ms)});
await assert.rejects(launch({instance:'coder'}), {code:'no-route'});
routed=true;
assert.deepEqual(await launch({instance:'coder'}), {run:'r1'});
assert.deepEqual(waits, []);
assert.equal(calls, 2);
});
test('disconnecting a delayed launch cancels it before it can reach the host', async () => {
const guard = runner.launchBackoff ?? (send => send);
let calls=0; const controller=new AbortController();
const launch=guard(async () => {calls++; throw Object.assign(new Error('instance-running'),{code:'instance-running'});},
{now:()=>0,wait:(ms,signal)=>new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(Object.assign(new Error('cancelled'),{code:'cancelled'})),{once:true}))});
await assert.rejects(launch({instance:'pm'}),{code:'instance-running'});
const waiting=launch({instance:'pm'},{signal:controller.signal}).catch(e=>e);
await turn(); assert.equal(calls,1,'no second host call before the waiting period');
controller.abort(); await waiting;
assert.equal(calls,1,'a disconnected caller cannot trigger a later launch');
});
test('simultaneous calls for one instance cannot bypass a refusal', async () => {
const guard = runner.launchBackoff ?? (send => send);
let calls=0, finish; const waits=[];
const launch=guard(async () => {
calls++;
if (calls === 1) await new Promise(resolve => { finish=resolve; });
throw Object.assign(new Error('instance-running'),{code:'instance-running'});
}, {now:()=>0,wait:ms=>new Promise(resolve=>waits.push({ms,resolve}))});
const first=launch({instance:'pm'}).catch(e=>e);
await turn();
const second=launch({instance:'pm'}).catch(e=>e);
await turn(); assert.equal(calls,1);
finish(); await first; await turn();
assert.equal(calls,1); assert.equal(waits.length,1);
waits[0].resolve(); await second; assert.equal(calls,2);
});
test('a disconnected tool socket cancels a launch waiting for backoff', async t => {
const guard = runner.launchBackoff ?? (send => send);
const dir=fs.mkdtempSync(join(tmpdir(),'backoff797-socket-'));
t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
let calls=0, waiting;
const launch=guard(async ()=>{calls++;throw Object.assign(new Error('instance-running'),{code:'instance-running'});},
{now:()=>0,wait:(ms,signal)=>new Promise((resolve,reject)=>{waiting={resolve,signal};signal.addEventListener('abort',()=>reject(new Error('cancelled')),{once:true});})});
const path=join(dir,'tools.sock');
const server=await runner.toolServer({path,tools:[{name:'launch',verb:'launch'}],client:{},local:{launch}});
await assert.rejects(callTool(path,'launch',{instance:'pm'}));
const socket=connect(path);
t.after(()=>{ socket.destroy(); return new Promise(resolve=>server.close(resolve)); });
socket.on('error',()=>{});
socket.write(JSON.stringify({tool:'launch',args:{instance:'pm'}})+'\n');
for(let n=0;n<100 && !waiting;n++) await new Promise(resolve=>setTimeout(resolve,2));
assert.ok(waiting); socket.destroy();
for(let n=0;n<100 && !waiting.signal.aborted;n++) await new Promise(resolve=>setTimeout(resolve,2));
assert.equal(waiting.signal.aborted,true);
waiting.resolve(); await turn(); assert.equal(calls,1);
});