mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5d8a95a2 | ||
|
|
6174bcd98b | ||
|
|
dab67d26bc | ||
|
|
e3631341f7 | ||
|
|
e0558737e6 | ||
|
|
7109278614 | ||
|
|
5809fb6bd9 | ||
|
|
393d5ca8e3 | ||
|
|
9bd3be4d66 | ||
|
|
0a856ca3d4 | ||
|
|
e49442233f | ||
|
|
086474b7d9 | ||
|
|
451ccef241 | ||
|
|
e5c908664d | ||
|
|
1ff7a2d7b2 | ||
|
|
ae6ef2021b | ||
|
|
7b2fe9b6d1 | ||
|
|
bbc08fab2e | ||
|
|
98545882c6 | ||
|
|
a638efcdb7 | ||
|
|
ad2c0fedb0 | ||
|
|
b0c2ff8e34 | ||
|
|
3137c3f330 | ||
|
|
e6e527b014 | ||
|
|
ed89da5f1f | ||
|
|
aa8f642e12 | ||
|
|
cd636f8792 | ||
|
|
12dfac8c39 | ||
|
|
69fc31394b | ||
|
|
92f8f7200b | ||
|
|
75e65e9eea | ||
|
|
e7e611a776 | ||
|
|
da0d5d1379 | ||
|
|
5f359b888d | ||
|
|
b69bea1d38 | ||
|
|
7c3c3d924f | ||
|
|
d5c97ba1ab | ||
|
|
5530e824e0 | ||
|
|
40a2f05e16 | ||
|
|
44470c8554 | ||
|
|
86c7db1a91 | ||
|
|
5696d8034f | ||
|
|
d7212df8d5 | ||
|
|
18bd7dfaa5 | ||
|
|
a7097d0a38 | ||
|
|
8549be66dd | ||
|
|
c0a680e358 | ||
|
|
d4a0772489 | ||
|
|
fb318b9b49 | ||
|
|
eebce55a73 | ||
|
|
5575771ad5 | ||
|
|
917bd021a3 | ||
|
|
15639a78fa | ||
|
|
88c84e5849 | ||
|
|
a5adc18ac8 | ||
|
|
a286675e46 | ||
|
|
9d03cac187 | ||
|
|
774e08bff4 | ||
|
|
f272fcd5b5 | ||
|
|
e1e3a6e159 | ||
|
|
acae60990b | ||
|
|
c86a92a8bf | ||
|
|
170bc87281 | ||
|
|
9fb35228a3 | ||
|
|
5b791d0a91 | ||
|
|
dea37a44b4 | ||
|
|
8235216474 | ||
|
|
4cb11fee0b | ||
|
|
6105a8632b | ||
|
|
c7508a2e0f | ||
|
|
a0658fb5de | ||
|
|
604302f29c | ||
|
|
7744a92c79 | ||
|
|
5f458cd750 | ||
|
|
fc03d7b652 | ||
|
|
d97bae68d6 | ||
|
|
77742e214e | ||
|
|
0f82a8e33d | ||
|
|
4def9cac73 | ||
|
|
a391bb3113 | ||
|
|
077783cad0 | ||
|
|
ee6c8d99b0 | ||
|
|
b956de01db | ||
|
|
0fd48fc927 | ||
|
|
9c8a5afc2d | ||
|
|
331f65e135 | ||
|
|
c005c97ff9 | ||
|
|
f57f1bc2e2 | ||
|
|
ecf193529e | ||
|
|
6d14e88531 | ||
|
|
72834509a1 |
No files matched your search
+20
-122
@@ -1,142 +1,40 @@
|
||||
name: Tests
|
||||
|
||||
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
|
||||
# list each published package would contain. Each suite is its own job so one failure does not
|
||||
# hide the others. No provider credentials or operator configuration are supplied.
|
||||
# Package suites deny external networking; scenario runtime has no external route.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
on: workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
stopped-fresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: b0043e9862c023e8e6e1989f698c018af10e03c3
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Record the published file list of each package
|
||||
- name: Intended stop generation and policy diagnosis
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p pack-lists
|
||||
for p in cli daemon tui; do
|
||||
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
|
||||
| sort > pack-lists/$p.txt
|
||||
echo "$p: $(wc -l < pack-lists/$p.txt) files"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pack-lists
|
||||
path: pack-lists/
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
repo-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
package-tests:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
# file-inode behaviour and release tags.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [daemon, cli, tui, ui]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: brew install tmux
|
||||
- run: npm ci
|
||||
- name: Build the packages the tests import
|
||||
run: npm run build
|
||||
- name: Run the suite without ambient credentials or external network
|
||||
run: |
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
|
||||
suite_root=$(cd "$suite_root" && pwd -P)
|
||||
mkdir -p "$suite_root/home" "$suite_root/bin"
|
||||
# Same system bytes, nonprivileged mode; no system file is changed.
|
||||
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
|
||||
# Only this job's temporary sockets (including tmux) are reachable.
|
||||
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
|
||||
CI=true TERM=xterm-256color \
|
||||
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
|
||||
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
|
||||
set -euo pipefail
|
||||
node scripts/ci-macos-process-probe.mjs
|
||||
# The production readers use bare ps. Select real system bytes only after
|
||||
# self/owned-child observation and network/IPC confinement are proved.
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
test "$(command -v ps)" = "$TMPDIR/bin/ps"
|
||||
exec npm run test -w "packages/$1"
|
||||
SUITE
|
||||
|
||||
installed-scenario:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Installed package, daemon restart, and seeded durability failure
|
||||
run: bash scripts/run-pr-scenarios.sh
|
||||
set +e
|
||||
set -uo pipefail
|
||||
mkdir -p proof
|
||||
git rev-parse HEAD HEAD^{tree} > proof/source.txt
|
||||
npm --workspace @openrig/daemon test -- test/stopped-fresh-up.test.ts test/stopped-seat-restore-contract.test.ts --reporter=verbose 2>&1 | tee proof/daemon.log
|
||||
result=${PIPESTATUS[0]}
|
||||
npm run lint 2>&1 | tee proof/typecheck.log
|
||||
lint_result=${PIPESTATUS[0]}
|
||||
git diff --exit-code > proof/source-unchanged.txt
|
||||
printf 'daemon=%s\ntypecheck=%s\n' "$result" "$lint_result" > proof/exits.txt
|
||||
test "$result" -eq 0 && test "$lint_result" -eq 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: installed-scenario-evidence
|
||||
path: dist/pr-scenarios/
|
||||
name: uneven-stop-matrix
|
||||
path: proof/
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
const source=path.resolve(process.env.SOURCE_ROOT),out=path.resolve(process.env.PROOF_ROOT);
|
||||
const home=path.join(out,'controls-home');fs.mkdirSync(home,{recursive:true});
|
||||
for(const k of Object.keys(process.env))if(/^(OPENRIG_|RIGGED_|CLAUDE_|CODEX_|ANTHROPIC_|OPENAI_|TMUX)/.test(k))delete process.env[k];
|
||||
Object.assign(process.env,{HOME:home,OPENRIG_HOME:path.join(home,'.openrig'),CODEX_HOME:path.join(home,'.codex')});
|
||||
const req=createRequire(path.join(source,'package.json')),Database=req('better-sqlite3');
|
||||
const load=rel=>import(pathToFileURL(path.join(source,'packages/daemon/dist',rel+'.js')));
|
||||
const {migrate}=await load('db/migrate'),{ALL_MIGRATIONS}=await load('db/all-migrations');
|
||||
const {RigRepository}=await load('domain/rig-repository'),{SessionRegistry}=await load('domain/session-registry');
|
||||
const {DiscoveryRepository}=await load('domain/discovery-repository'),{EventBus}=await load('domain/event-bus');
|
||||
const {ClaimService}=await load('domain/claim-service');
|
||||
const {SeatDeliveryGuard,resolveGuardTarget}=await load('domain/seat-delivery-guard');
|
||||
const records=[];
|
||||
for(const mode of ['nonsole','unobservable']){
|
||||
const db=new Database(':memory:');migrate(db,ALL_MIGRATIONS);
|
||||
try{
|
||||
const rigRepo=new RigRepository(db),sessionRegistry=new SessionRegistry(db),eventBus=new EventBus(db);
|
||||
const rig=rigRepo.createRig('private-controls'),node=rigRepo.addNode(rig.id,'worker',{runtime:'claude-code',podId:null});
|
||||
const seat='worker@private-controls';sessionRegistry.registerSession(node.id,seat);sessionRegistry.updateBinding(node.id,{tmuxSession:seat});
|
||||
const calls=[];const tmux={hasSession:async()=>true,listPanes:async()=>{if(mode==='unobservable')throw Error('fixture observation unavailable');return[{id:'%1'},{id:'%2'}];},getPaneCommand:async()=> 'claude',setSessionOption:async(...args)=>{calls.push(['metadata',args]);return{ok:true};},sendText:async()=>{throw Error('forbidden input');},sendKeys:async()=>{throw Error('forbidden input');},createSession:async()=>{throw Error('forbidden launch');}};
|
||||
tmux.deliveryGuard=new SeatDeliveryGuard(db,name=>resolveGuardTarget(db,name));
|
||||
const svc=new ClaimService({db,rigRepo,sessionRegistry,eventBus,discoveryRepo:new DiscoveryRepository(db),tmuxAdapter:tmux,transcriptStore:{enabled:false}});
|
||||
const result=await svc.reconcileSession({sessionName:seat});
|
||||
const binding=sessionRegistry.getBindingForNode(node.id),sessions=db.prepare('SELECT * FROM sessions WHERE node_id=? ORDER BY id').all(node.id);
|
||||
const identity=db.prepare('SELECT * FROM seat_identity_verdicts WHERE node_id=?').get(node.id);
|
||||
assert.equal(result.ok,true);assert.equal(binding.tmuxPane,null);assert(result.result.projectionDrift.some(x=>x.includes(mode==='nonsole'?'2 panes':'fixture observation unavailable')));
|
||||
records.push({mode,label:'synthetic pane observation seam; real SQLite/ClaimService; no CLI or real tmux control',result,binding,sessions,identity,calls,sendabilityNotRestored:true});
|
||||
}finally{db.close();}
|
||||
}
|
||||
fs.writeFileSync(path.join(out,'CONTROLS.json'),JSON.stringify(records,null,2));
|
||||
console.log('two remaining observation controls completed; HTTP/ok is not pane-repair proof');
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const ef=promisify(execFile), ex=promisify(exec);
|
||||
const source=path.resolve(process.env.SOURCE_ROOT), out=path.resolve(process.env.PROOF_ROOT);
|
||||
const home=path.join(out,'scratch-home'), socket=path.join(out,'fixture.sock');
|
||||
fs.mkdirSync(home,{recursive:true});
|
||||
// This process and every CLI child receive only the scratch homes and owned endpoint.
|
||||
for(const key of Object.keys(process.env)) if(/^(OPENRIG_|RIGGED_|CLAUDE_|CODEX_|ANTHROPIC_|OPENAI_|TMUX)/.test(key)) delete process.env[key];
|
||||
Object.assign(process.env,{HOME:home,OPENRIG_HOME:path.join(home,'.openrig'),XDG_CONFIG_HOME:path.join(home,'.config'),CODEX_HOME:path.join(home,'.codex')});
|
||||
const bin=path.join(home,'bin');fs.mkdirSync(bin,{recursive:true});
|
||||
process.env.PATH=bin+':'+process.env.PATH;
|
||||
fs.writeFileSync(path.join(bin,'codex'),'#!/bin/sh\ncase "$1" in --version) echo "codex-cli 0.139.0";; --help) echo "Usage: codex [OPTIONS] [PROMPT]"; echo "--no-daemon";; esac\n',{mode:0o755});
|
||||
// Inert stdin recorder; its executable name/argv can be read by the unmodified runtime observer.
|
||||
fs.writeFileSync(path.join(out,'recorder.c'),`#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\nint main(int argc,char **argv){if(argc>1&&!strcmp(argv[1],"--version")){puts("2.1.0 (Claude Code)");return 0;}if(argc>1&&!strcmp(argv[1],"--help")){puts("Usage: claude [options] [prompt]");return 0;}setvbuf(stdout,NULL,_IONBF,0);puts("FIXTURE_RECORDER_READY");FILE *f=fopen(getenv("FIXTURE_RECORD"),"ab");if(!f)return 2;char b[4096];while(fgets(b,sizeof b,stdin)){fputs(b,f);fflush(f);printf("RECORDER_READ:%s",b);}fclose(f);return 0;}\n`);
|
||||
await ef('cc',['-O0',path.join(out,'recorder.c'),'-o',path.join(bin,'claude')]);
|
||||
const q=s=>"'"+s.replaceAll("'","'\\''")+"'";
|
||||
const records={identity:{label:'public-cut source-built CLI/createDaemon; inert recorder, not sealed archive/native provider',source:process.env.SOURCE_PIN},commands:[],cli:[],checks:{}};
|
||||
const save=()=>fs.writeFileSync(path.join(out,'RESULT.json'),JSON.stringify(records,null,2));
|
||||
let phase='startup',daemon,server;
|
||||
const tmuxExec=async command=>{assert.match(command,/^tmux\b/);records.commands.push({phase,command});return (await ex(command.replace(/^tmux\b/,'tmux -S '+q(socket)),{env:process.env,timeout:10000,maxBuffer:4*1024*1024})).stdout;};
|
||||
const tmux=async(...args)=>(await ef('tmux',['-S',socket,...args],{env:process.env,timeout:10000})).stdout;
|
||||
const req=createRequire(path.join(source,'package.json'));
|
||||
const {serve}=await import(pathToFileURL(req.resolve('@hono/node-server')));
|
||||
const {createDaemon}=await import(pathToFileURL(path.join(source,'packages/daemon/dist/startup.js')));
|
||||
const {HistoryQuery}=await import(pathToFileURL(path.join(source,'packages/daemon/dist/domain/history-query.js')));
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const token='00000000-0000-4000-8000-000000000001';
|
||||
const cli=async args=>{const startedAt=new Date().toISOString();let r;try{const x=await ef(process.execPath,[path.join(source,'packages/cli/dist/index.js'),...args],{env:process.env,timeout:25000,maxBuffer:4*1024*1024});r={code:0,...x};}catch(e){r={code:typeof e.code==='number'?e.code:1,stdout:e.stdout??'',stderr:e.stderr??'',error:e.message};}records.cli.push({phase,args,startedAt,endedAt:new Date().toISOString(),...r});save();return r;};
|
||||
const data=r=>{try{return JSON.parse(r.stdout);}catch{return null;}};
|
||||
try{
|
||||
daemon=await createDaemon({dbPath:path.join(home,'fixture.sqlite'),tmuxExec,terminalBearerToken:null,bearerToken:null,webUiEnabled:false});
|
||||
server=serve({fetch:daemon.app.fetch,hostname:'127.0.0.1',port:0});
|
||||
await new Promise(r=>server.listening?r():server.once('listening',r));
|
||||
const port=server.address().port;process.env.OPENRIG_URL='http://127.0.0.1:'+port;
|
||||
records.identity.endpoint=process.env.OPENRIG_URL;records.identity.socket=socket;records.identity.home=home;
|
||||
const {db,deps}=daemon,{rigRepo,sessionRegistry,transcriptStore,queueRepo}=deps;
|
||||
const rig=rigRepo.createRig('pane-fixture');
|
||||
async function seat(logical,nullable){const name=logical.replaceAll('.','-')+'@pane-fixture',record=path.join(out,logical+'.stdin');
|
||||
await tmux('new-session','-d','-s',name,'-x','120','-y','35',`FIXTURE_RECORD=${q(record)} exec ${q(path.join(bin,'claude'))} --resume ${token}`);
|
||||
let state='';for(let n=0;n<50;n++){state=await tmux('capture-pane','-p','-t',name);if(state.includes('FIXTURE_RECORDER_READY'))break;await sleep(40);}assert.match(state,/FIXTURE_RECORDER_READY/);
|
||||
const node=rigRepo.addNode(rig.id,logical,{runtime:'claude-code',cwd:home,podId:null});
|
||||
const session=sessionRegistry.registerSession(node.id,name);sessionRegistry.updateStatus(session.id,'running');sessionRegistry.updateStartupStatus(session.id,'ready',new Date().toISOString());
|
||||
const pane=(await tmux('display-message','-p','-t',name,'#{pane_id}')).trim();
|
||||
sessionRegistry.updateBinding(node.id,{tmuxSession:name,...(nullable?{}:{tmuxPane:pane})});
|
||||
// Preserve the reported runtime/resume-type anomaly; never normalize it in this fixture.
|
||||
sessionRegistry.updateResumeToken(session.id,'codex_id',token,'hook');
|
||||
return {name,node,session,pane,record};}
|
||||
const target=await seat('legacy.fixture',true),control=await seat('control.fixture',false);
|
||||
const transcriptPath=transcriptStore.getTranscriptPath(rig.name,target.name);
|
||||
transcriptStore.ensureTranscriptDir(rig.name);fs.writeFileSync(transcriptPath,'HISTORY_SENTINEL before reconcile\n--- SESSION BOUNDARY: retained fixture marker ---\nHISTORY_SENTINEL prior boundary\n');
|
||||
const history=new HistoryQuery({transcriptsRoot:path.dirname(path.dirname(transcriptPath)),exec:async()=>{throw Error('native history backend not selected');}});
|
||||
const obligation=await queueRepo.create({qitemId:'qitem-owned-legacy-qualification',sourceSession:control.name,destinationSession:target.name,body:'Owned fixture obligation survives reconcile',nudge:false});
|
||||
const facts=async()=>({binding:db.prepare('SELECT * FROM bindings WHERE node_id=?').get(target.node.id),sessions:db.prepare('SELECT * FROM sessions WHERE node_id=? ORDER BY id').all(target.node.id),tenures:db.prepare('SELECT * FROM occupant_tenures WHERE node_id=? ORDER BY generation_ordinal').all(target.node.id),node:db.prepare('SELECT * FROM nodes WHERE id=?').get(target.node.id),queue:db.prepare('SELECT * FROM queue_items WHERE qitem_id=?').get(obligation.qitemId),tmux:(await tmux('display-message','-p','-t',target.name,'#{pid}|#{pane_id}|#{pane_pid}|#{session_id}')).trim(),pane:await tmux('capture-pane','-p','-t',target.name),stdin:fs.existsSync(target.record)?fs.readFileSync(target.record,'utf8'):'',transcript:fs.readFileSync(transcriptPath,'utf8'),history:await history.searchSeat(rig.name,target.name,'HISTORY_SENTINEL'),transcriptPath});
|
||||
phase='matching-control';const matching=await cli(['send',control.name,'MATCHING_BINDING_CONTROL','--json']);assert.equal(matching.code,0);assert.equal(data(matching)?.ok,true);
|
||||
phase='before-refusal';const initial=await facts();records.before=initial;save();const refused=await cli(['send',target.name,'MUST_NOT_REACH_NULL_BINDING','--json']);assert.notEqual(refused.code,0);assert.match(refused.stdout+refused.stderr,/Managed pane identity unavailable or changed/);assert.equal(fs.readFileSync(target.record,'utf8'),initial.stdin);
|
||||
phase='reconcile';const beforeReconcile=await facts();records.beforeReconcile=beforeReconcile;const seq=db.prepare('SELECT max(seq) AS seq FROM events').get().seq;
|
||||
const result=await cli(['reconcile-session',target.name,'--no-launch','--json']);records.reconcileBody=data(result);records.afterReconcile=await facts();records.reconcileEvents=db.prepare('SELECT * FROM events WHERE seq>? ORDER BY seq').all(seq);save();
|
||||
assert.equal(result.code,0);assert.equal(records.reconcileBody?.ok,true);assert.equal(records.reconcileBody.result.continuity,'unverified');
|
||||
assert.equal(records.afterReconcile.binding.tmux_pane,target.pane);assert.equal(records.afterReconcile.tmux,beforeReconcile.tmux);assert.equal(records.afterReconcile.stdin,beforeReconcile.stdin);assert.equal(records.afterReconcile.pane,beforeReconcile.pane);
|
||||
const forbidden=records.commands.filter(x=>x.phase==='reconcile'&&/\b(?:send-keys|paste-buffer|load-buffer|new-session|respawn-pane|kill-session|kill-pane)\b/.test(x.command));records.checks.reconcileForbiddenCommands=forbidden;assert.equal(forbidden.length,0);
|
||||
phase='after-send';const sent=await cli(['send',target.name,'AFTER_RECONCILE_MARKER','--json']);assert.equal(sent.code,0);assert.equal(data(sent)?.ok,true);
|
||||
for(let n=0;n<40;n++){if(fs.readFileSync(target.record,'utf8').includes('AFTER_RECONCILE_MARKER'))break;await sleep(50);}records.afterSend=await facts();assert.match(records.afterSend.stdin,/AFTER_RECONCILE_MARKER/);assert.match(records.afterSend.pane,/AFTER_RECONCILE_MARKER/);
|
||||
records.checks.queueBytesPreserved=JSON.stringify(initial.queue)===JSON.stringify(records.afterReconcile.queue);assert.equal(records.checks.queueBytesPreserved,true);
|
||||
records.checks.historyMarkersPreserved=records.afterReconcile.transcript.includes('HISTORY_SENTINEL before reconcile')&&records.afterReconcile.transcript.includes('retained fixture marker');
|
||||
records.checks.oldResumeAnomalyPreserved=records.afterReconcile.sessions.find(s=>s.id===target.session.id).resume_type==='codex_id'&&records.afterReconcile.node.runtime==='claude-code';assert.equal(records.checks.oldResumeAnomalyPreserved,true);
|
||||
records.checks.sessionRowsAdded=records.afterReconcile.sessions.length-initial.sessions.length;records.checks.tenuresAdded=records.afterReconcile.tenures.length-initial.tenures.length;
|
||||
records.status='completed';await db.backup(path.join(out,'fixture-after.sqlite'));save();
|
||||
}catch(error){records.status='failed';records.failure={message:error.message,stack:error.stack,phase};save();process.exitCode=1;}
|
||||
finally{phase='owned-shutdown';try{await tmux('kill-server');records.ownedTmuxShutdown=true;}catch(e){records.ownedTmuxShutdown={error:e.message};}try{if(server)await new Promise(r=>server.close(r));}catch{}try{daemon?.db.close();}catch{}save();}
|
||||
process.exit(process.exitCode??0);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js";
|
||||
|
||||
const token = "22222222-2222-4222-8222-222222222222";
|
||||
const history = "Claude Code v2.1.303\n❯ predecessor finished turn\n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const binding: NodeBinding = { id: "binding", nodeId: "node", tmuxSession: "seat", tmuxPane: "%9",
|
||||
tmuxWindow: null, cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/fixture", launchGeneration: 7 };
|
||||
function fixture() {
|
||||
const capturePaneScreen = vi.fn(async (): Promise<string | null> => "operator@host$ ");
|
||||
const capturePaneContent = vi.fn(async () => history);
|
||||
const getPaneCommand = vi.fn(async () => "zsh");
|
||||
const getPanePid = vi.fn(async (): Promise<number | null> => 100);
|
||||
const listProcesses = vi.fn((): NativeProcessRow[] => { throw new Error("ps unavailable"); });
|
||||
const tmux = { hasSession: vi.fn(async () => true), getPaneCommand, capturePaneContent, capturePaneScreen,
|
||||
sendText: vi.fn(async () => ({ ok: true })), sendKeys: vi.fn(async () => ({ ok: true })),
|
||||
listPanes: vi.fn(async () => [{ id: "%9" }]), getPanePid } as unknown as TmuxAdapter;
|
||||
const fail = () => { throw new Error("unexpected filesystem access"); };
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sessionIdFactory: () => token, sleep: async () => {},
|
||||
fsOps: { exists: () => false, readFile: fail, writeFile: fail, mkdirp: fail, copyFile: fail } });
|
||||
return { adapter, capturePaneScreen, capturePaneContent, getPaneCommand, getPanePid, listProcesses };
|
||||
}
|
||||
const launch = (adapter: ClaudeCodeAdapter, opts: Partial<Parameters<RuntimeAdapter["launchHarness"]>[1]> = {}) =>
|
||||
adapter.launchHarness(binding, { name: "seat", freshInPlace: true, ...opts });
|
||||
|
||||
describe("fresh in-place readiness scope and fallback", () => {
|
||||
it.each(["adopted", "ordinary-fresh", "resume", "fork"])("preserves %s readiness without reading the current frame", async mode => {
|
||||
const f = fixture();
|
||||
if (mode === "ordinary-fresh") await f.adapter.launchHarness(binding, { name: "seat" });
|
||||
if (mode === "resume") await launch(f.adapter, { resumeToken: token });
|
||||
if (mode === "fork") await launch(f.adapter, { forkSource: { kind: "native_id", value: token } });
|
||||
expect(await f.adapter.checkReady(binding)).toEqual({ ready: true });
|
||||
expect(f.capturePaneScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["id", "nodeId", "tmuxSession", "tmuxPane", "launchGeneration"] as const)("does not reuse an intent for a different %s", async field => {
|
||||
const f = fixture();
|
||||
await launch(f.adapter);
|
||||
const changed = { ...binding, [field]: field === "launchGeneration" ? 8 : "changed" };
|
||||
expect(await f.adapter.checkReady(changed)).toEqual({ ready: true });
|
||||
expect(f.capturePaneScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, "", " "])("retains old admission when the current frame is unavailable (%s)", async screen => {
|
||||
const f = fixture();
|
||||
f.capturePaneScreen.mockResolvedValue(screen);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toEqual({ ready: true });
|
||||
expect(f.capturePaneScreen).toHaveBeenCalledWith("%9");
|
||||
});
|
||||
it("retains old admission on a failed screen observation", async () => {
|
||||
const f = fixture();
|
||||
f.capturePaneScreen.mockRejectedValue(new Error("capture unavailable"));
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toEqual({ ready: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Accessing workspace:\nYes, I trust this folder", "trust_gate"],
|
||||
["new MCP servers found in .mcp.json\nSelect any you wish to enable\nEnter to confirm", "mcp_gate"],
|
||||
[CLAUDE_BYPASS_CONSENT, "bypass_consent_gate"],
|
||||
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "claude_resume_selection_prompt"],
|
||||
["● Not logged in · Please run /login\n❯\u00a0", "login_required"],
|
||||
["No conversation found", "no_conversation_found"],
|
||||
])("does not replace a visible gate with the history fallback: %s", async (screen, code) => {
|
||||
const f = fixture();
|
||||
f.capturePaneScreen.mockResolvedValue(screen);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code });
|
||||
});
|
||||
|
||||
it("does not promote a historically blocked result from a ready-looking current frame", async () => {
|
||||
const f = fixture();
|
||||
f.capturePaneContent.mockResolvedValue("Accessing workspace:\nYes, I trust this folder");
|
||||
f.capturePaneScreen.mockResolvedValue(history);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "trust_gate" });
|
||||
expect(f.capturePaneScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["claude", "claude --unrecognized-option opaque --session-id apparent-token"])("keeps unknown identity unknown: %s", async command => {
|
||||
for (const screen of [null, history,
|
||||
"Restored conversation\n─────────\n❯ \n─────────\n ⏵⏵ auto mode on (shift+tab to cycle)",
|
||||
"Restored conversation\n─────────\n❯ \n─────────\n ⏵⏵ accept edits on (shift+tab to cycle)"]) {
|
||||
const f = fixture();
|
||||
f.getPaneCommand.mockResolvedValue("sh");
|
||||
f.capturePaneScreen.mockResolvedValue(screen);
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockReturnValue([
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "claude", command, startedAt },
|
||||
]);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding), JSON.stringify({ command, screen })).toEqual({ ready: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["missing-pane-pid", "missing-pane-row", "background-claude"])("does not claim absence from incomplete pane evidence: %s", async mode => {
|
||||
const f = fixture();
|
||||
f.getPaneCommand.mockResolvedValue("sh");
|
||||
f.capturePaneScreen.mockResolvedValue("❯ ");
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockReturnValue(mode === "missing-pane-row" ? [] : [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 100, executableName: "bash", command: "-bash", startedAt },
|
||||
...(mode === "background-claude" ? [
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 100, executableName: "claude", command: "claude", startedAt },
|
||||
] : []),
|
||||
]);
|
||||
if (mode === "missing-pane-pid") f.getPanePid.mockResolvedValue(null);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toEqual({ ready: true });
|
||||
});
|
||||
|
||||
it("does not use unavailable-screen fallback for a positively different launch identity", async () => {
|
||||
const f = fixture();
|
||||
f.capturePaneScreen.mockResolvedValue(null);
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockImplementation(() => [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "claude", command: "claude --session-id different-token", startedAt },
|
||||
]);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "claude_launch_identity_conflict" });
|
||||
});
|
||||
|
||||
it("keeps a positively different runtime blocked despite unavailable screen evidence", async () => {
|
||||
const f = fixture();
|
||||
f.capturePaneScreen.mockResolvedValue(null);
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockReturnValue([
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token", startedAt },
|
||||
]);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "claude_launch_identity_conflict" });
|
||||
});
|
||||
|
||||
it("does not carry a ready observation across a replacement launch", async () => {
|
||||
const f = fixture();
|
||||
await launch(f.adapter);
|
||||
f.capturePaneScreen.mockImplementation(async () => {
|
||||
await f.adapter.launchHarness(binding, { name: "replacement" });
|
||||
return history;
|
||||
});
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "awaiting_runtime" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// Reuses the released handover fixture. Process startup and pane bytes are modeled; no native Claude.
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const observations: unknown[] = [];
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SeatHandoverService } from "../src/domain/seat-handover-service.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { observeCodexSandbox } from "../src/domain/permission-drift.js";
|
||||
import { type OccupantInvalidator } from "../src/domain/occupant-invalidator.js";
|
||||
|
||||
describe("Claude fresh in-place handover readiness", () => {
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let discoveryRepo: DiscoveryRepository;
|
||||
let eventBus: EventBus;
|
||||
let hasSession: ReturnType<typeof vi.fn>;
|
||||
let createSession: ReturnType<typeof vi.fn>;
|
||||
let listPanes: ReturnType<typeof vi.fn>;
|
||||
let killSession: ReturnType<typeof vi.fn>;
|
||||
let respawnPane: ReturnType<typeof vi.fn>;
|
||||
let setRemainOnExit: ReturnType<typeof vi.fn>;
|
||||
let signalPaneProcess: ReturnType<typeof vi.fn>;
|
||||
let isPaneDead: ReturnType<typeof vi.fn>;
|
||||
let sendText: ReturnType<typeof vi.fn>;
|
||||
let sendKeys: ReturnType<typeof vi.fn>;
|
||||
let capturePaneScreen: ReturnType<typeof vi.fn>;
|
||||
let launchHarness: ReturnType<typeof vi.fn>;
|
||||
let checkReady: ReturnType<typeof vi.fn>;
|
||||
let readSidecar: ReturnType<typeof vi.fn>;
|
||||
let captureCodexThreadId: ReturnType<typeof vi.fn>;
|
||||
let claudeProcessStartedAt: ((sessionName: string) => Promise<string | null>) | undefined;
|
||||
let invalidateRetiringOccupant: ReturnType<typeof vi.fn>;
|
||||
let declareOccupantSwap: ReturnType<typeof vi.fn>;
|
||||
let resolvePredecessorRecap: ReturnType<typeof vi.fn>;
|
||||
let getDefaultShell: ReturnType<typeof vi.fn>;
|
||||
let getPaneCommand: ReturnType<typeof vi.fn>;
|
||||
let service: SeatHandoverService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
discoveryRepo = new DiscoveryRepository(db);
|
||||
eventBus = new EventBus(db);
|
||||
hasSession = vi.fn(async () => true);
|
||||
createSession = vi.fn(async () => ({ ok: true }));
|
||||
listPanes = vi.fn(async () => [{ id: "%9", index: 0, cwd: "/project", width: 80, height: 24, active: true }]);
|
||||
killSession = vi.fn(async () => ({ ok: true }));
|
||||
respawnPane = vi.fn(async () => ({ ok: true }));
|
||||
setRemainOnExit = vi.fn(async () => ({ ok: true }));
|
||||
signalPaneProcess = vi.fn(async () => ({ ok: true }));
|
||||
isPaneDead = vi.fn(async () => true); // cutover: retiree exits gracefully on SIGTERM
|
||||
sendText = vi.fn(async () => ({ ok: true }));
|
||||
sendKeys = vi.fn(async () => ({ ok: true }));
|
||||
capturePaneScreen = vi.fn(async () => "predecessor screen tail");
|
||||
// B1 — a fresh successor is launched into a live agent (launchHarness +
|
||||
// readiness) with a scraped resume token (B2 launched-mode).
|
||||
launchHarness = vi.fn(async () => ({
|
||||
ok: true,
|
||||
resumeToken: "codex-launch-tok",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: observeCodexSandbox(" -s workspace-write"),
|
||||
}));
|
||||
checkReady = vi.fn(async () => ({ ready: true }));
|
||||
// B2 — discovered-mode derive-helper deps (Codex thread-id capturer by default).
|
||||
readSidecar = vi.fn(() => ({ ok: true, data: { session_id: "claude-sid-123" } }));
|
||||
captureCodexThreadId = vi.fn(async () => "codex-discovered-tok");
|
||||
claudeProcessStartedAt = undefined;
|
||||
invalidateRetiringOccupant = vi.fn();
|
||||
declareOccupantSwap = vi.fn();
|
||||
resolvePredecessorRecap = vi.fn(() => ({ unavailableReason: "test default: no record" }));
|
||||
// KI-14: healthy default — the respawned pane comes up as a blank shell.
|
||||
getDefaultShell = vi.fn(async () => "/bin/zsh");
|
||||
getPaneCommand = vi.fn(async () => "zsh");
|
||||
service = newService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function tmux(): TmuxAdapter {
|
||||
return { hasSession, createSession, listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead, sendText, sendKeys, capturePaneScreen, getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function codexAdapter(): RuntimeAdapter {
|
||||
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
|
||||
}
|
||||
|
||||
function newService(adapter: TmuxAdapter = tmux(), occupantInvalidator: OccupantInvalidator = { invalidateRetiringOccupant }): SeatHandoverService {
|
||||
return new SeatHandoverService({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
discoveryRepo,
|
||||
eventBus,
|
||||
tmuxAdapter: adapter,
|
||||
now: () => new Date("2026-04-24T18:30:00.000Z"),
|
||||
newSuccessorId: () => "01SUCCID0",
|
||||
runtimeAdapters: { codex: codexAdapter() },
|
||||
contextUsageStore: { readSidecar } as never,
|
||||
resumeTokenCapturer: { captureCodexThreadId } as never,
|
||||
...(claudeProcessStartedAt ? { claudeProcessStartedAt } : {}),
|
||||
occupantInvalidator,
|
||||
activityOracle: { declareOccupantSwap },
|
||||
predecessorRecapResolver: resolvePredecessorRecap as never,
|
||||
readinessTimeoutMs: 50,
|
||||
sleep: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function seedSeat(opts?: { rigName?: string; runtime?: string; withSession?: boolean; model?: string; codexConfigProfile?: string; effort?: string }) {
|
||||
const rig = rigRepo.createRig(opts?.rigName ?? "seat-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: opts?.runtime ?? "codex", cwd: "/project", model: opts?.model, codexConfigProfile: opts?.codexConfigProfile, effort: opts?.effort });
|
||||
let sessionId: string | null = null;
|
||||
if (opts?.withSession !== false) {
|
||||
const session = sessionRegistry.registerSession(node.id, `dev-impl@${rig.name}`);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateStartupStatus(session.id, "ready", "2026-04-20T12:00:00Z");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: `dev-impl@${rig.name}`, tmuxPane: "%0" });
|
||||
sessionId = session.id;
|
||||
}
|
||||
return { rig, node, sessionId };
|
||||
}
|
||||
|
||||
|
||||
it.each(["classic-predecessor-history", "screen-without-predecessor-history", "new-runtime-already-ready", "stale-wrapper-screen-unavailable", "working-wrapper-screen-unavailable", "working-headerless-auto-proof", "working-headerless-auto-unavailable", "working-headerless-accept-edits-unavailable", "working-headerless-bypass-unavailable",
|
||||
...["shell", "script"].flatMap(process => ["prompt", "command"].flatMap(frame =>
|
||||
["readable", "unreadable"].map(listing => `classic-chevron-${process}-${frame}-${listing}`))),
|
||||
])("1544 %s", async mode => {
|
||||
const { node } = seedSeat({ runtime: "claude-code" });
|
||||
let elapsed = 0, requested = false, packetPasted = false;
|
||||
const chevron = mode.startsWith("classic-chevron-");
|
||||
const script = chevron && mode.includes("-script-");
|
||||
const unreadable = chevron && mode.endsWith("-unreadable");
|
||||
const shellPrompt = chevron ? mode.includes("-command-") ? "❯ /bin/sh fixture-launch.txt" : "❯ " : "operator@host$ ";
|
||||
const headerless = mode.startsWith("working-headerless-");
|
||||
const auto = mode.startsWith("working-headerless-auto");
|
||||
const bypass = mode === "working-headerless-bypass-unavailable";
|
||||
if (auto || bypass) new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: auto ? "auto" : "bypassPermissions" }, "fixture", "headerless qualification");
|
||||
const nativeAt = mode === "new-runtime-already-ready" || mode === "working-wrapper-screen-unavailable" || headerless ? 0 : 500;
|
||||
const unavailable = mode.endsWith("screen-unavailable");
|
||||
const identicalFrame = "Claude Code v2.1.303\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const autoFrame = "Restored conversation\n─────────\n❯ \n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents";
|
||||
const headerlessFrame = auto ? autoFrame : autoFrame.replace("auto mode on", bypass ? "bypass permissions on" : "accept edits on");
|
||||
const oldFrame = "Claude Code v2.1.303\n❯ predecessor finished turn\n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const freshFrame = "Claude Code v2.1.303\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const trace: unknown[] = [];
|
||||
const pane = () => headerless ? identicalFrame + "\n" + headerlessFrame : unavailable ? identicalFrame : requested && elapsed >= nativeAt ? freshFrame
|
||||
: mode === "classic-predecessor-history" || chevron ? oldFrame + "\n" + shellPrompt : shellPrompt;
|
||||
getPaneCommand.mockImplementation(async () => (unavailable || headerless) && requested ? "sh" : requested && elapsed >= nativeAt ? "claude" : requested && script ? "sh" : "zsh");
|
||||
capturePaneScreen.mockImplementation(async (target: string) => {
|
||||
const returnedPane = unavailable ? null : headerless ? headerlessFrame : requested && elapsed < nativeAt ? shellPrompt : pane();
|
||||
trace.push({ kind: "current-frame", target, elapsed, returnedPane });
|
||||
return returnedPane;
|
||||
});
|
||||
const capturePaneContent = vi.fn(async (_session: string, lines: number) => {
|
||||
trace.push({ kind: "history", elapsed, lines, returnedPane: pane() });
|
||||
return pane();
|
||||
});
|
||||
const target = { ...tmux(), capturePaneContent, getPanePid: vi.fn(async () => 100),
|
||||
sendShellCommand: async (session: string, text: string) => sendText(session, text) } as unknown as TmuxAdapter;
|
||||
const fsError = () => { throw new Error("unexpected filesystem access"); };
|
||||
const listProcesses = vi.fn(() => {
|
||||
if (unavailable || unreadable || mode.endsWith("-unavailable")) {
|
||||
trace.push({ kind: "process-read", elapsed, available: false });
|
||||
throw new Error("fixture ps unavailable");
|
||||
}
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
if (chevron) {
|
||||
const running = requested && elapsed >= nativeAt;
|
||||
const foreground = script ? 101 : running ? 102 : 100;
|
||||
const rows = [{ pid: 100, ppid: 1, pgid: 100, tpgid: foreground,
|
||||
command: "-zsh", executableName: "zsh", startedAt },
|
||||
...(script ? [{ pid: 101, ppid: 100, pgid: 101, tpgid: 101,
|
||||
command: "/bin/sh fixture-launch.txt", executableName: "sh", startedAt }] : []),
|
||||
...(running ? [{ pid: 102, ppid: script ? 101 : 100, pgid: foreground, tpgid: foreground,
|
||||
command: `claude --session-id ${successorToken}`, executableName: "claude", startedAt }] : [])];
|
||||
trace.push({ kind: "process-read", elapsed, available: true, rows });
|
||||
return rows;
|
||||
}
|
||||
if (mode !== "working-headerless-auto-proof") return [];
|
||||
return [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, command: "-bash", executableName: "bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, command: "/bin/sh fixture-launch.txt", executableName: "sh", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, command: `/opt/claude.exe --permission-mode auto --session-id ${successorToken} --name dev-impl@seat-rig`, executableName: "claude", startedAt },
|
||||
];
|
||||
});
|
||||
const claude = new ClaudeCodeAdapter({ tmux: target, fsOps: { exists: () => false, readFile: fsError,
|
||||
writeFile: fsError, mkdirp: fsError, copyFile: fsError }, listProcesses, sleep: async () => {}, sessionIdFactory: () => successorToken,
|
||||
...(headerless ? { claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
|
||||
assertCurrent: () => {}, configDir: "/fixture" }) } as never } : {}) });
|
||||
const reads: unknown[] = [];
|
||||
const readiness = vi.fn(async (binding) => {
|
||||
const result = await claude.checkReady(binding);
|
||||
reads.push({ elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, result });
|
||||
return result;
|
||||
});
|
||||
const successorToken = "22222222-2222-4222-8222-222222222222";
|
||||
sendText.mockImplementation(async (session: string, text: string) => {
|
||||
if (text.includes("claude ") && text.includes("--session-id")) {
|
||||
requested = true; trace.push({ kind: "launch-text", elapsed, session, text, nativeAt });
|
||||
return { ok: true };
|
||||
}
|
||||
packetPasted = true;
|
||||
trace.push({ kind: "packet-paste", session, elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, text });
|
||||
return { ok: true };
|
||||
});
|
||||
sendKeys.mockImplementation(async (session: string, keys: string[]) => {
|
||||
trace.push({ kind: packetPasted ? "input" : "launch-input", session, elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, keys });
|
||||
return { ok: true };
|
||||
});
|
||||
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
|
||||
tmuxAdapter: target, now: () => new Date("2026-10-19T21:30:00Z"), newSuccessorId: () => "01SUCCID0",
|
||||
runtimeAdapters: { "claude-code": { runtime: "claude-code", launchHarness: (binding, opts) => { trace.push({ kind: "launch-intent", opts }); return claude.launchHarness(binding, opts); }, checkReady: readiness } as unknown as RuntimeAdapter },
|
||||
contextUsageStore: { readSidecar } as never, resumeTokenCapturer: { captureCodexThreadId } as never,
|
||||
occupantInvalidator: { invalidateRetiringOccupant }, activityOracle: { declareOccupantSwap },
|
||||
predecessorRecapResolver: resolvePredecessorRecap as never, readinessTimeoutMs: 5000,
|
||||
sleep: async ms => { elapsed += ms; trace.push({ kind: "sleep", ms, elapsed }); },
|
||||
});
|
||||
const result = await handover.handover({ seatRef: "dev-impl@seat-rig", source: "fresh", reason: "bounded scrollback reproduction" });
|
||||
const sessions = db.prepare("SELECT session_name,status,resume_token,startup_status FROM sessions ORDER BY id").all();
|
||||
observations.push({ mode, result, trace, reads, listProcessesCalls: listProcesses.mock.calls.length,
|
||||
sessions, respawn: respawnPane.mock.calls });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(reads.length).toBeGreaterThan(0);
|
||||
const first = reads[0] as any;
|
||||
if (chevron) {
|
||||
expect(first.result.ready).toBe(unreadable);
|
||||
expect(first.successorRuntimeStarted).toBe(false);
|
||||
const packet = trace.find((x: any) => x.kind === "packet-paste") as any;
|
||||
expect(packet.elapsed).toBe(unreadable ? 0 : 1000);
|
||||
expect(packet.successorRuntimeStarted).toBe(!unreadable);
|
||||
expect(trace).toContainEqual({ kind: "current-frame", target: "%9", elapsed: 0, returnedPane: shellPrompt });
|
||||
expect(trace.some((x: any) => x.kind === "process-read" && x.available === !unreadable)).toBe(true);
|
||||
} else if (mode === "classic-predecessor-history") {
|
||||
expect(first.result.ready).toBe(false);
|
||||
expect(first.successorRuntimeStarted).toBe(false);
|
||||
expect((trace.find((x: any) => x.kind === "packet-paste") as any).elapsed).toBeGreaterThanOrEqual(nativeAt);
|
||||
expect((reads.at(-1) as any).result.ready).toBe(true);
|
||||
expect(trace).toContainEqual({ kind: "current-frame", target: "%9", elapsed: 0, returnedPane: "operator@host$ " });
|
||||
} else if (headerless) {
|
||||
const launched = trace.find((x: any) => x.kind === "launch-text") as any;
|
||||
if (auto) expect(launched.text).toMatch(/--permission-mode auto(?: |$)/);
|
||||
if (bypass) expect(launched.text).toMatch(/--permission-mode bypassPermissions(?: |$)/);
|
||||
if (!auto && !bypass) expect(launched.text).toMatch(/--permission-mode acceptEdits(?: |$)/);
|
||||
expect(first.result.ready).toBe(true);
|
||||
expect(first.successorRuntimeStarted).toBe(true);
|
||||
expect(capturePaneScreen).toHaveBeenCalledWith("%9");
|
||||
} else if (unavailable) {
|
||||
// O1 deliberately preserves admission for this indistinguishable stale/working pair.
|
||||
expect(first.result.ready).toBe(true);
|
||||
expect(first.successorRuntimeStarted).toBe(mode === "working-wrapper-screen-unavailable");
|
||||
expect(capturePaneScreen).toHaveBeenCalledWith("%9");
|
||||
} else if (mode === "new-runtime-already-ready") expect(first.result.ready).toBe(true);
|
||||
else {
|
||||
expect(first.result.ready).toBe(false);
|
||||
expect((reads.at(-1) as any).result.ready).toBe(true);
|
||||
}
|
||||
expect(trace.find((x: any) => x.kind === "launch-intent")).toMatchObject({ opts: { freshInPlace: true } });
|
||||
expect(respawnPane).toHaveBeenCalled();
|
||||
expect(killSession).not.toHaveBeenCalled();
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
const dir = process.env.CHECKPOINT_OUTPUT;
|
||||
if (!dir) return;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "observations.json"), JSON.stringify(observations, null, 2) + "\n");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// Diagnosis only: unchanged production source, modeled captures and input effects.
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SessionTransport, classifyPaneActivity, probeSessionActivity, readInputBox, readSeatBeforePaste } from "../src/domain/session-transport.js";
|
||||
import { findComposerInputLineIndex } from "../src/domain/composer-prompts.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const quoted = "• Ran rig capture peer\n › 1. Yes, proceed (y)\n 2. No (esc)\n Press enter to confirm or esc to cancel";
|
||||
const working = "• Working (23s • esc to interrupt)";
|
||||
const reported = `${quoted}\n${working}\n› Ask Codex to do anything\nGPT-6-Astra default\n? for shortcuts`;
|
||||
const supportedBox = "› Ask Codex to do anything\n\n GPT-6-Astra default · ~/code\n ? for shortcuts";
|
||||
const supported = `${quoted}\n${working}\n\n${supportedBox}`;
|
||||
const menu = "Would you like to run the following command?\n› 1. Yes, proceed (y)\n 2. No (esc)\nPress enter to confirm or esc to cancel";
|
||||
const rule = "─".repeat(60);
|
||||
const claudeBar = " ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const oldQuote = "● Earlier report: Do you want to proceed?";
|
||||
const claudeFrame = (head: string, draft = "") => `${oldQuote}\n\n${head}\n\n${rule}\n❯ ${draft}\n${rule}\n${claudeBar}`;
|
||||
type Case = { id: string; pane: string | null; runtime?: "codex" | "claude-code"; current?: boolean; fault?: boolean };
|
||||
const cases: Case[] = [
|
||||
{ id: "reported-synthetic", pane: reported },
|
||||
{ id: "supported-bottom-frame", pane: supported },
|
||||
{ id: "supported-idle-bottom-frame", pane: `${quoted}\n\n${supportedBox}` },
|
||||
{ id: "reported-without-quoted-choice", pane: `${working}\n› Ask Codex to do anything\nGPT-6-Astra default\n? for shortcuts` },
|
||||
{ id: "current-codex-dialog", pane: menu, current: true },
|
||||
{ id: "working-prose-before-dialog", pane: `${working}\n${menu}`, current: true },
|
||||
{ id: "working-prose-after-dialog", pane: `${menu}\n${working}`, current: true },
|
||||
{ id: "current-dialog-under-status", pane: `${working}\n${menu}\n\n${supportedBox}`, current: true },
|
||||
// Equal bytes, alternate underlying meaning: no classifier can distinguish intent labels.
|
||||
{ id: "supported-equal-render-current-dialog", pane: supported, current: true },
|
||||
{ id: "clipped-codex-permission", pane: "Would you like to run the following command?\n GPT-6-Astra default", current: true },
|
||||
{ id: "tall-claude-picker", runtime: "claude-code", pane: ["Choose", "❯ 1. Yes", ...Array.from({ length: 18 }, () => " option description"), " 2. No", "Enter to select · ↑/↓ to navigate · Esc to cancel"].join("\n"), current: true },
|
||||
{ id: "clipped-claude-permission", runtime: "claude-code", pane: `Do you want to proceed?\n${rule}\n❯ \n${rule}\n${claudeBar}`, current: true },
|
||||
{ id: "codex-draft", pane: supportedBox.replace("Ask Codex to do anything", "human draft") },
|
||||
{ id: "quoted-choice-in-draft", pane: supportedBox.replace("Ask Codex to do anything", "human draft\n › 1. Yes, proceed (y)") },
|
||||
{ id: "bare-choice-history-unknown-frame", pane: `${quoted}\nunknown footer` },
|
||||
{ id: "bare-working-unknown-frame", pane: `${working}\nunknown footer` },
|
||||
{ id: "unknown-frame", pane: "Unrecognized terminal content" },
|
||||
{ id: "null-capture", pane: null },
|
||||
{ id: "failed-capture", pane: null, fault: true },
|
||||
// Useful #1323 boundary controls, recreated without its unmerged production patch.
|
||||
{ id: "1323-completed-claude-history", runtime: "claude-code", pane: claudeFrame("✻ Crunched for 2s") },
|
||||
{ id: "1323-current-question", runtime: "claude-code", pane: claudeFrame("Do you want to proceed?"), current: true },
|
||||
{ id: "1323-draft", runtime: "claude-code", pane: claudeFrame("✻ Crunched for 2s", "typed draft") },
|
||||
{ id: "1323-unknown-footer", runtime: "claude-code", pane: claudeFrame("✻ Crunched for 2s").replace(claudeBar, "unknown footer") },
|
||||
];
|
||||
for (const name of ["after-enter-0.4s.J.txt", "after-enter-15s.J.txt"]) {
|
||||
const pane = readFileSync(new URL(`./fixtures/input-box/codex-0.160.0/${name}`, import.meta.url), "utf8");
|
||||
const lines = pane.split("\n");
|
||||
const at = findComposerInputLineIndex(lines, "›»");
|
||||
if (at < 0) throw new Error("retained fixture lacks composer");
|
||||
lines.splice(at, 0, quoted, working, "");
|
||||
cases.push({ id: `shipped-frame-plus-synthetic-quote:${name}`, pane: lines.join("\n") });
|
||||
}
|
||||
|
||||
const rows: unknown[] = [];
|
||||
afterAll(() => {
|
||||
const dir = process.env.CHECKPOINT_OUTPUT;
|
||||
if (!dir) throw new Error("private evidence output required");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "observations.json"), JSON.stringify(rows, null, 2) + "\n");
|
||||
});
|
||||
|
||||
describe("1533 unchanged classifier and guarded-send diagnosis", () => {
|
||||
for (const hook of ["none", "running", "permission"] as const) {
|
||||
it.each(cases)(`${hook}: $id`, async fixture => {
|
||||
const runtime = fixture.runtime ?? "codex";
|
||||
const pane = fixture.pane;
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const now = new Date("2026-10-19T15:30:00Z");
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const store = new AgentActivityStore({ db, eventBus });
|
||||
const rig = rigRepo.createRig("diag1533");
|
||||
const node = rigRepo.addNode(rig.id, "dev.test", { role: "worker", runtime });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-test@diag1533");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-test@diag1533" });
|
||||
if (hook !== "none") store.recordHookEvent({ runtime, sessionName: "dev-test@diag1533",
|
||||
hookEvent: hook === "running" ? "UserPromptSubmit" : runtime === "codex" ? "PermissionRequest" : "Notification",
|
||||
...(hook === "permission" && runtime === "claude-code" ? { subtype: "permission_prompt" } : {}), occurredAt: now.toISOString() });
|
||||
const effects: unknown[] = [];
|
||||
const sendText = vi.fn(async (_target: string, value: string) => { effects.push({ paste: value }); return { ok: true as const }; });
|
||||
const sendKeys = vi.fn(async (_target: string, value: string[]) => { effects.push({ keys: value }); return { ok: true as const }; });
|
||||
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
capturePaneContent: async () => { if (fixture.fault) throw new Error("controlled failed capture"); return pane; },
|
||||
sendText, sendKeys, getPaneCommand: async () => null, getPanePid: async () => null,
|
||||
listSessions: async () => [], listWindows: async () => [], listPanes: async () => [],
|
||||
} as unknown as TmuxAdapter;
|
||||
const classifier = classifyPaneActivity(pane ?? "", { runtime });
|
||||
const probe = await probeSessionActivity({ sessionName: "dev-test@diag1533", runtime, attachmentType: "tmux", tmuxAdapter: tmux, now });
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, eventBus, agentActivityStore: store,
|
||||
tmuxAdapter: tmux, now: () => now, sleep: async () => {} });
|
||||
const result = await transport.send("dev-test@diag1533", "ordinary status update");
|
||||
// Counterfactual scope experiment only, NOT a production correction. A supported box and
|
||||
// status locate a suffix; the equal-render control exposes what that DOES NOT prove.
|
||||
const inputBox = readInputBox(pane, runtime, "ordinary status update");
|
||||
const lines = (pane ?? "").split("\n");
|
||||
const inputAt = findComposerInputLineIndex(lines, runtime === "codex" ? "›»" : "❯");
|
||||
const statusAt = lines.findLastIndex((line, i) => i < inputAt && /^• Working \([^\n]*esc to interrupt\)/.test(line.trim()));
|
||||
const proposedBoundary = runtime === "codex" && inputBox === "empty" && statusAt >= 0 ? statusAt : null;
|
||||
const suffixVerdict = proposedBoundary === null ? null : classifyPaneActivity(lines.slice(proposedBoundary).join("\n"), { runtime });
|
||||
rows.push({ id: fixture.id, hook, runtime, pane, currentDialogIntent: fixture.current ?? false,
|
||||
classifier, probe, result, effects, pasteCount: sendText.mock.calls.length, keyCount: sendKeys.mock.calls.length,
|
||||
inputBox, seatBeforePaste: readSeatBeforePaste(pane, runtime), inputAt, statusAt, proposedBoundary, suffixVerdict });
|
||||
if (fixture.id === "reported-synthetic") expect(classifier).toMatchObject({ state: "attention", reason: "selection_prompt" });
|
||||
if (hook === "permission" || (fixture.current && hook === "none")) {
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
expect(sendText).not.toHaveBeenCalled(); expect(sendKeys).not.toHaveBeenCalled();
|
||||
}
|
||||
if (fixture.id === "reported-synthetic" && hook === "none") expect(result).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
} finally { db.close(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Bounded diagnosis only: reported menu text, modeled pane/process reads, no native Claude or approval input.
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { assessNativeResumeProbe } from "../src/domain/native-resume-probe.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { classifyPaneActivity, SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { transportRoutes } from "../src/routes/transport.js";
|
||||
|
||||
const oldMenu = ` 2 new MCP servers found in .mcp.json
|
||||
Select any you wish to enable.
|
||||
|
||||
MCP servers may execute code or access system resources. All tool calls
|
||||
require approval. Learn more in the MCP documentation.
|
||||
|
||||
❯ [✔] dummy1
|
||||
[✔] dummy2
|
||||
Space to select · Enter to confirm · Esc to reject all`;
|
||||
const newMenu = oldMenu.replace("in .mcp.json", "in this project")
|
||||
.replace(" Space to select · Enter to confirm · Esc to reject all", " Enable selected\n Space to select · Esc to reject all");
|
||||
const rule = "─".repeat(60);
|
||||
const composer = `Claude Code v2.1.303\n${rule}\n❯ \n${rule}\n ⏵⏵ accept edits on (shift+tab to cycle)`;
|
||||
const frames = [
|
||||
{ id: "old-current-MCP", pane: oldMenu },
|
||||
{ id: "2.1.303-current-MCP", pane: newMenu },
|
||||
{ id: "genuine-composer", pane: composer },
|
||||
{ id: "old-MCP-history-before-composer", pane: `${oldMenu}\n\n${composer}` },
|
||||
{ id: "new-MCP-history-before-composer", pane: `${newMenu}\n\n${composer}` },
|
||||
{ id: "ordinary-current-permission", pane: "Do you want to proceed?\n❯ 1. Yes\n 2. No\nEnter to select · ↑/↓ to navigate · Esc to cancel" },
|
||||
{ id: "MCP-prose-before-composer", pane: `The project has new MCP servers. Select any you wish to enable.\n\n${composer}` },
|
||||
{ id: "clipped-new-MCP", pane: newMenu.split("\n").slice(0, 3).join("\n") },
|
||||
{ id: "current-checkbox-cursor-without-normal-composer", pane: "Choose\n❯ [✔] option\nSpace to select · Esc to reject all" },
|
||||
];
|
||||
const cleanup: Array<() => void | Promise<void>> = [];
|
||||
const records: unknown[] = [];
|
||||
afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); vi.restoreAllMocks(); });
|
||||
afterAll(() => {
|
||||
const dir = process.env.CHECKPOINT_OUTPUT;
|
||||
if (!dir) throw new Error("diagnostic output required");
|
||||
mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "observations.json"), JSON.stringify(records, null, 2) + "\n");
|
||||
});
|
||||
|
||||
describe("1541 actual assessment, checkReady and HTTP send observations", () => {
|
||||
it.each(frames)("$id", async frame => {
|
||||
const db = new Database(":memory:"); cleanup.push(() => db.close()); migrate(db, ALL_MIGRATIONS);
|
||||
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
|
||||
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES ('node','rig','worker','claude-code');
|
||||
INSERT INTO bindings(id,node_id,tmux_session,tmux_pane) VALUES ('binding','node','worker@test','%1');
|
||||
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('session','node','worker@test','running');
|
||||
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('generation','node',1,'g1','fresh');`);
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); guard.recoverActivation();
|
||||
let staged = "", command = "claude";
|
||||
const effects: Array<{ kind: string; argv: string[]; text?: string }> = [];
|
||||
const tmux = new TmuxAdapter(async () => "", {
|
||||
writeFile: async (_path, text) => { staged = text; }, unlink: async () => {}, tmpName: () => "/fixture/input", bufferName: () => "input",
|
||||
}, async argv => {
|
||||
if (argv[1] === "paste-buffer") effects.push({ kind: "paste", argv: [...argv], text: staged });
|
||||
if (argv[1] === "send-keys") effects.push({ kind: "keys", argv: [...argv] });
|
||||
return "";
|
||||
});
|
||||
tmux.deliveryGuard = guard;
|
||||
vi.spyOn(tmux, "probeSession").mockResolvedValue({ state: "present" });
|
||||
vi.spyOn(tmux, "listPanes").mockResolvedValue([{ id: "%1" } as never]);
|
||||
vi.spyOn(tmux, "getPaneCommand").mockImplementation(async () => command);
|
||||
vi.spyOn(tmux, "getPanePid").mockResolvedValue(null);
|
||||
vi.spyOn(tmux, "capturePaneContent").mockResolvedValue(frame.pane);
|
||||
const fsError = () => { throw new Error("unexpected filesystem effect"); };
|
||||
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: {
|
||||
exists: () => false, readFile: fsError, writeFile: fsError, mkdirp: fsError, copyFile: fsError,
|
||||
}, listProcesses: () => [], sleep: async () => {} });
|
||||
const binding = { id: "binding", nodeId: "node", tmuxSession: "worker@test", tmuxWindow: null, tmuxPane: "%1",
|
||||
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/fixture" };
|
||||
const assessments = [
|
||||
{ paneCommand: "bash", verified: false }, { paneCommand: "bash", verified: true }, { paneCommand: "claude", verified: false },
|
||||
].map(input => ({ ...input, result: assessNativeResumeProbe({ runtime: "claude-code", paneContent: frame.pane,
|
||||
paneCommand: input.paneCommand, ...(input.verified ? { claudeResumeIdentityVerified: true } : {}) }) }));
|
||||
const readiness: unknown[] = [];
|
||||
for (const paneCommand of ["bash", "claude"]) { command = paneCommand; readiness.push({ paneCommand, result: await adapter.checkReady(binding) }); }
|
||||
command = "claude";
|
||||
const now = new Date("2026-10-19T19:30:00Z");
|
||||
const eventBus = new EventBus(db), activity = new AgentActivityStore({ db, eventBus });
|
||||
const transport = new SessionTransport({ db, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db), tmuxAdapter: tmux,
|
||||
eventBus, agentActivityStore: activity, now: () => now, sleep: async () => {} });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("sessionTransport" as never, transport); await next(); });
|
||||
app.route("/api/transport", transportRoutes());
|
||||
const server = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }) as Server;
|
||||
cleanup.push(() => new Promise<void>((resolve, reject) => { server.closeAllConnections(); server.close(error => error ? reject(error) : resolve()); }));
|
||||
await new Promise<void>(resolve => server.once("listening", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const sends: unknown[] = [];
|
||||
for (const hook of ["none", "running", "permission"] as const) {
|
||||
if (hook !== "none") activity.recordHookEvent({ runtime: "claude-code", sessionName: "worker@test",
|
||||
hookEvent: hook === "running" ? "UserPromptSubmit" : "Notification", ...(hook === "permission" ? { subtype: "permission_prompt" } : {}), occurredAt: now.toISOString() });
|
||||
effects.length = 0;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/transport/send`, { method: "POST",
|
||||
headers: { "content-type": "application/json", "x-openrig-session": "sender@test" },
|
||||
body: JSON.stringify({ session: "worker@test", text: "ordinary status update" }) });
|
||||
const body = await response.json();
|
||||
sends.push({ hook, status: response.status, body, effects: structuredClone(effects) });
|
||||
expect(response.status).toBe(body.ok ? 200 : 409);
|
||||
expect(effects.map(x => x.kind)).toEqual(body.ok ? ["paste", "keys"] : []);
|
||||
if (hook === "permission") expect(body).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
if (frame.id === "2.1.303-current-MCP" && hook === "none") expect(body.ok).toBe(true); // Reproduced defect, NOT desired safety behavior.
|
||||
}
|
||||
if (frame.id === "old-current-MCP") expect(assessments[0]!.result).toMatchObject({ status: "inconclusive", code: "mcp_gate" });
|
||||
if (frame.id === "2.1.303-current-MCP") expect(assessments[1]!.result).toMatchObject({ status: "resumed", code: "verified_native_identity" });
|
||||
records.push({ id: frame.id, pane: frame.pane, assessments, readiness, classifier: classifyPaneActivity(frame.pane, { runtime: "claude-code" }), sends });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
// Reuses the released handover fixture. Process startup and pane bytes are modeled; no native Claude.
|
||||
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { assessNativeResumeProbe } from "../src/domain/native-resume-probe.js";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const observations: unknown[] = [];
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { DiscoveryRepository } from "../src/domain/discovery-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SeatHandoverService } from "../src/domain/seat-handover-service.js";
|
||||
import { WatchdogAutoRegistration } from "../src/domain/watchdog-auto-registration.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { WatchdogHistoryLog } from "../src/domain/watchdog-history-log.js";
|
||||
import { WatchdogPolicyEngine, type DeliveryFn } from "../src/domain/watchdog-policy-engine.js";
|
||||
import { makeIdleGateQitemPolicy } from "../src/domain/policies/idle-gate-qitem.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { watchdogHistorySchema } from "../src/db/migrations/032_watchdog_history.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { observeCodexSandbox } from "../src/domain/permission-drift.js";
|
||||
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { DefaultOccupantInvalidator, type OccupantInvalidator } from "../src/domain/occupant-invalidator.js";
|
||||
|
||||
describe("SeatHandoverService", () => {
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let discoveryRepo: DiscoveryRepository;
|
||||
let eventBus: EventBus;
|
||||
let hasSession: ReturnType<typeof vi.fn>;
|
||||
let createSession: ReturnType<typeof vi.fn>;
|
||||
let listPanes: ReturnType<typeof vi.fn>;
|
||||
let killSession: ReturnType<typeof vi.fn>;
|
||||
let respawnPane: ReturnType<typeof vi.fn>;
|
||||
let setRemainOnExit: ReturnType<typeof vi.fn>;
|
||||
let signalPaneProcess: ReturnType<typeof vi.fn>;
|
||||
let isPaneDead: ReturnType<typeof vi.fn>;
|
||||
let sendText: ReturnType<typeof vi.fn>;
|
||||
let sendKeys: ReturnType<typeof vi.fn>;
|
||||
let capturePaneScreen: ReturnType<typeof vi.fn>;
|
||||
let launchHarness: ReturnType<typeof vi.fn>;
|
||||
let checkReady: ReturnType<typeof vi.fn>;
|
||||
let readSidecar: ReturnType<typeof vi.fn>;
|
||||
let captureCodexThreadId: ReturnType<typeof vi.fn>;
|
||||
let claudeProcessStartedAt: ((sessionName: string) => Promise<string | null>) | undefined;
|
||||
let invalidateRetiringOccupant: ReturnType<typeof vi.fn>;
|
||||
let declareOccupantSwap: ReturnType<typeof vi.fn>;
|
||||
let resolvePredecessorRecap: ReturnType<typeof vi.fn>;
|
||||
let getDefaultShell: ReturnType<typeof vi.fn>;
|
||||
let getPaneCommand: ReturnType<typeof vi.fn>;
|
||||
let service: SeatHandoverService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
discoveryRepo = new DiscoveryRepository(db);
|
||||
eventBus = new EventBus(db);
|
||||
hasSession = vi.fn(async () => true);
|
||||
createSession = vi.fn(async () => ({ ok: true }));
|
||||
listPanes = vi.fn(async () => [{ id: "%9", index: 0, cwd: "/project", width: 80, height: 24, active: true }]);
|
||||
killSession = vi.fn(async () => ({ ok: true }));
|
||||
respawnPane = vi.fn(async () => ({ ok: true }));
|
||||
setRemainOnExit = vi.fn(async () => ({ ok: true }));
|
||||
signalPaneProcess = vi.fn(async () => ({ ok: true }));
|
||||
isPaneDead = vi.fn(async () => true); // cutover: retiree exits gracefully on SIGTERM
|
||||
sendText = vi.fn(async () => ({ ok: true }));
|
||||
sendKeys = vi.fn(async () => ({ ok: true }));
|
||||
capturePaneScreen = vi.fn(async () => "predecessor screen tail");
|
||||
// B1 — a fresh successor is launched into a live agent (launchHarness +
|
||||
// readiness) with a scraped resume token (B2 launched-mode).
|
||||
launchHarness = vi.fn(async () => ({
|
||||
ok: true,
|
||||
resumeToken: "codex-launch-tok",
|
||||
resumeType: "codex_id",
|
||||
appliedLaunch: observeCodexSandbox(" -s workspace-write"),
|
||||
}));
|
||||
checkReady = vi.fn(async () => ({ ready: true }));
|
||||
// B2 — discovered-mode derive-helper deps (Codex thread-id capturer by default).
|
||||
readSidecar = vi.fn(() => ({ ok: true, data: { session_id: "claude-sid-123" } }));
|
||||
captureCodexThreadId = vi.fn(async () => "codex-discovered-tok");
|
||||
claudeProcessStartedAt = undefined;
|
||||
invalidateRetiringOccupant = vi.fn();
|
||||
declareOccupantSwap = vi.fn();
|
||||
resolvePredecessorRecap = vi.fn(() => ({ unavailableReason: "test default: no record" }));
|
||||
// KI-14: healthy default — the respawned pane comes up as a blank shell.
|
||||
getDefaultShell = vi.fn(async () => "/bin/zsh");
|
||||
getPaneCommand = vi.fn(async () => "zsh");
|
||||
service = newService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function tmux(): TmuxAdapter {
|
||||
return { hasSession, createSession, listPanes, killSession, respawnPane, setRemainOnExit, signalPaneProcess, isPaneDead, sendText, sendKeys, capturePaneScreen, getDefaultShell, getPaneCommand } as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function codexAdapter(): RuntimeAdapter {
|
||||
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
|
||||
}
|
||||
|
||||
function newService(adapter: TmuxAdapter = tmux(), occupantInvalidator: OccupantInvalidator = { invalidateRetiringOccupant }): SeatHandoverService {
|
||||
return new SeatHandoverService({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
discoveryRepo,
|
||||
eventBus,
|
||||
tmuxAdapter: adapter,
|
||||
now: () => new Date("2026-04-24T18:30:00.000Z"),
|
||||
newSuccessorId: () => "01SUCCID0",
|
||||
runtimeAdapters: { codex: codexAdapter() },
|
||||
contextUsageStore: { readSidecar } as never,
|
||||
resumeTokenCapturer: { captureCodexThreadId } as never,
|
||||
...(claudeProcessStartedAt ? { claudeProcessStartedAt } : {}),
|
||||
occupantInvalidator,
|
||||
activityOracle: { declareOccupantSwap },
|
||||
predecessorRecapResolver: resolvePredecessorRecap as never,
|
||||
readinessTimeoutMs: 50,
|
||||
sleep: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function seedSeat(opts?: { rigName?: string; runtime?: string; withSession?: boolean; model?: string; codexConfigProfile?: string; effort?: string }) {
|
||||
const rig = rigRepo.createRig(opts?.rigName ?? "seat-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: opts?.runtime ?? "codex", cwd: "/project", model: opts?.model, codexConfigProfile: opts?.codexConfigProfile, effort: opts?.effort });
|
||||
let sessionId: string | null = null;
|
||||
if (opts?.withSession !== false) {
|
||||
const session = sessionRegistry.registerSession(node.id, `dev-impl@${rig.name}`);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateStartupStatus(session.id, "ready", "2026-04-20T12:00:00Z");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: `dev-impl@${rig.name}`, tmuxPane: "%0" });
|
||||
sessionId = session.id;
|
||||
}
|
||||
return { rig, node, sessionId };
|
||||
}
|
||||
|
||||
|
||||
it.each(["classic-predecessor-history", "screen-without-predecessor-history", "new-runtime-already-ready", "stale-wrapper-screen-unavailable", "working-wrapper-screen-unavailable", "working-headerless-auto-proof", "working-headerless-auto-unavailable"])("1544 %s", async mode => {
|
||||
const { node } = seedSeat({ runtime: "claude-code" });
|
||||
let elapsed = 0, requested = false, packetPasted = false;
|
||||
const headerless = mode.startsWith("working-headerless-auto");
|
||||
if (headerless) new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "headerless auto qualification");
|
||||
const nativeAt = mode === "new-runtime-already-ready" || mode === "working-wrapper-screen-unavailable" || headerless ? 0 : 500;
|
||||
const unavailable = mode.endsWith("screen-unavailable");
|
||||
let currentScreenInput = false;
|
||||
const identicalFrame = "Claude Code v2.1.303\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const autoFrame = "Restored conversation\n─────────\n❯ \n─────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents";
|
||||
const oldFrame = "Claude Code v2.1.303\n❯ predecessor finished turn\n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const freshFrame = "Claude Code v2.1.303\n❯ \n ⏵⏵ accept edits on (shift+tab to cycle)";
|
||||
const trace: unknown[] = [];
|
||||
const pane = () => headerless ? identicalFrame + "\n" + autoFrame : unavailable ? identicalFrame : requested && elapsed >= nativeAt ? freshFrame
|
||||
: mode === "classic-predecessor-history" ? oldFrame + "\noperator@host$ " : "operator@host$ ";
|
||||
getPaneCommand.mockImplementation(async () => (unavailable || headerless) && requested ? "sh" : requested && elapsed >= nativeAt ? "claude" : "zsh");
|
||||
capturePaneScreen.mockImplementation(async () => unavailable ? null : headerless ? autoFrame : requested && elapsed < nativeAt ? "operator@host$ " : pane());
|
||||
const capturePaneContent = vi.fn(async (_session: string, lines: number) => {
|
||||
const returnedPane = currentScreenInput ? (unavailable ? null : headerless ? autoFrame : requested && elapsed < nativeAt ? "operator@host$ " : pane()) : pane();
|
||||
trace.push({ kind: "read", reader: currentScreenInput ? "current-screen-input" : "40-line-input", elapsed, lines,
|
||||
successorRuntimeStarted: requested && elapsed >= nativeAt, returnedPane });
|
||||
return returnedPane;
|
||||
});
|
||||
const target = { ...tmux(), capturePaneContent, getPanePid: vi.fn(async () => 100),
|
||||
sendShellCommand: async (session: string, text: string) => sendText(session, text) } as unknown as TmuxAdapter;
|
||||
const fsError = () => { throw new Error("unexpected filesystem access"); };
|
||||
const listProcesses = vi.fn(() => {
|
||||
if (unavailable || mode === "working-headerless-auto-unavailable") throw new Error("fixture ps unavailable");
|
||||
if (mode !== "working-headerless-auto-proof") return [];
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
return [
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, command: "-bash", executableName: "bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, command: "/bin/sh fixture-launch.txt", executableName: "sh", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, command: `/opt/claude.exe --permission-mode auto --session-id ${successorToken} --name dev-impl@seat-rig`, executableName: "claude", startedAt },
|
||||
];
|
||||
});
|
||||
const claude = new ClaudeCodeAdapter({ tmux: target, fsOps: { exists: () => false, readFile: fsError,
|
||||
writeFile: fsError, mkdirp: fsError, copyFile: fsError }, listProcesses, sleep: async () => {}, sessionIdFactory: () => successorToken,
|
||||
...(headerless ? { claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
|
||||
assertCurrent: () => {}, configDir: "/fixture" }) } as never } : {}) });
|
||||
const reads: unknown[] = [];
|
||||
const readiness = vi.fn(async (binding) => {
|
||||
const result = await claude.checkReady(binding);
|
||||
// Counterfactual reader qualification only: same public method with visible-screen bytes,
|
||||
// no production source edit, no fresh-launch refusal shipped.
|
||||
currentScreenInput = true;
|
||||
const withCurrentScreen = await claude.checkReady(binding);
|
||||
currentScreenInput = false;
|
||||
reads.push({ elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, result, withCurrentScreen });
|
||||
return result;
|
||||
});
|
||||
const successorToken = "22222222-2222-4222-8222-222222222222";
|
||||
sendText.mockImplementation(async (session: string, text: string) => {
|
||||
if (text.includes("claude ") && text.includes("--session-id")) {
|
||||
requested = true; trace.push({ kind: "launch-text", elapsed, session, text, nativeAt });
|
||||
return { ok: true };
|
||||
}
|
||||
packetPasted = true;
|
||||
trace.push({ kind: "packet-paste", session, elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, text });
|
||||
return { ok: true };
|
||||
});
|
||||
sendKeys.mockImplementation(async (session: string, keys: string[]) => {
|
||||
trace.push({ kind: packetPasted ? "input" : "launch-input", session, elapsed, successorRuntimeStarted: requested && elapsed >= nativeAt, keys });
|
||||
return { ok: true };
|
||||
});
|
||||
const handover = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus,
|
||||
tmuxAdapter: target, now: () => new Date("2026-10-19T21:30:00Z"), newSuccessorId: () => "01SUCCID0",
|
||||
runtimeAdapters: { "claude-code": { runtime: "claude-code", launchHarness: claude.launchHarness.bind(claude), checkReady: readiness } as unknown as RuntimeAdapter },
|
||||
contextUsageStore: { readSidecar } as never, resumeTokenCapturer: { captureCodexThreadId } as never,
|
||||
occupantInvalidator: { invalidateRetiringOccupant }, activityOracle: { declareOccupantSwap },
|
||||
predecessorRecapResolver: resolvePredecessorRecap as never, readinessTimeoutMs: 5000,
|
||||
sleep: async ms => { elapsed += ms; trace.push({ kind: "sleep", ms, elapsed }); },
|
||||
});
|
||||
const result = await handover.handover({ seatRef: "dev-impl@seat-rig", source: "fresh", reason: "bounded scrollback reproduction" });
|
||||
const sessions = db.prepare("SELECT session_name,status,resume_token,startup_status FROM sessions ORDER BY id").all();
|
||||
const oldProbe = assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "zsh", paneContent: oldFrame + "\noperator@host$ " });
|
||||
observations.push({ mode, result, trace, reads, listProcessesCalls: listProcesses.mock.calls.length,
|
||||
sessions, oldProbe, respawn: respawnPane.mock.calls });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(reads.length).toBeGreaterThan(0);
|
||||
const first = reads[0] as any;
|
||||
if (mode === "classic-predecessor-history") {
|
||||
expect(first.result.ready).toBe(true);
|
||||
expect(first.successorRuntimeStarted).toBe(false);
|
||||
expect(first.withCurrentScreen.ready).toBe(false);
|
||||
} else if (headerless) {
|
||||
const launched = trace.find((x: any) => x.kind === "launch-text") as any;
|
||||
expect(launched.text).toMatch(/--permission-mode auto(?: |$)/);
|
||||
expect(first.result.ready).toBe(true);
|
||||
expect(first.successorRuntimeStarted).toBe(true);
|
||||
expect(first.withCurrentScreen.ready).toBe(mode === "working-headerless-auto-proof");
|
||||
if (mode === "working-headerless-auto-unavailable") expect(first.withCurrentScreen.code).toBe("claude_auto_identity_required");
|
||||
} else if (unavailable) {
|
||||
expect(first.result.ready).toBe(true);
|
||||
expect(first.withCurrentScreen).toMatchObject({ ready: false, code: "awaiting_runtime" });
|
||||
expect(first.successorRuntimeStarted).toBe(mode === "working-wrapper-screen-unavailable");
|
||||
} else if (mode === "new-runtime-already-ready") expect(first.withCurrentScreen.ready).toBe(true);
|
||||
else {
|
||||
expect(first.result.ready).toBe(false);
|
||||
expect((reads.at(-1) as any).withCurrentScreen.ready).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
const dir = process.env.CHECKPOINT_OUTPUT;
|
||||
if (!dir) throw new Error("diagnostic output directory required");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "observations.json"), JSON.stringify(observations, null, 2) + "\n");
|
||||
});
|
||||
Reference in new issue
Block a user