@farmslot/agent-runtime 0.8.1 → 0.10.0
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.
- package/CHANGELOG.md +26 -1
- package/README.md +2 -2
- package/bin/farmslot-agent.mjs +3 -43
- package/dist/execution-template/create.d.ts.map +1 -1
- package/dist/execution-template/create.js +4 -9
- package/dist/execution-template/create.js.map +1 -1
- package/dist/execution-template/execution-template.test.js +85 -34
- package/dist/execution-template/execution-template.test.js.map +1 -1
- package/dist/execution-template/frontmatter.d.ts +6 -3
- package/dist/execution-template/frontmatter.d.ts.map +1 -1
- package/dist/execution-template/frontmatter.js +8 -10
- package/dist/execution-template/frontmatter.js.map +1 -1
- package/dist/execution-template/index.d.ts +2 -2
- package/dist/execution-template/index.d.ts.map +1 -1
- package/dist/execution-template/index.js +2 -2
- package/dist/execution-template/index.js.map +1 -1
- package/dist/execution-template/infer.d.ts +2 -3
- package/dist/execution-template/infer.d.ts.map +1 -1
- package/dist/execution-template/infer.js +2 -19
- package/dist/execution-template/infer.js.map +1 -1
- package/dist/execution-template/lint.d.ts.map +1 -1
- package/dist/execution-template/lint.js +12 -14
- package/dist/execution-template/lint.js.map +1 -1
- package/dist/execution-template/resolve.d.ts.map +1 -1
- package/dist/execution-template/resolve.js +0 -2
- package/dist/execution-template/resolve.js.map +1 -1
- package/dist/execution-template/select.d.ts.map +1 -1
- package/dist/execution-template/select.js +4 -8
- package/dist/execution-template/select.js.map +1 -1
- package/dist/execution-template/snapshot.d.ts.map +1 -1
- package/dist/execution-template/snapshot.js +0 -1
- package/dist/execution-template/snapshot.js.map +1 -1
- package/dist/execution-template/types.d.ts +10 -5
- package/dist/execution-template/types.d.ts.map +1 -1
- package/dist/execution-template/types.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/native/account-claude.d.ts +5 -0
- package/dist/native/account-claude.d.ts.map +1 -0
- package/dist/native/account-claude.js +42 -0
- package/dist/native/account-claude.js.map +1 -0
- package/dist/native/account-codex.d.ts +4 -0
- package/dist/native/account-codex.d.ts.map +1 -0
- package/dist/native/account-codex.js +51 -0
- package/dist/native/account-codex.js.map +1 -0
- package/dist/native/account-common.d.ts +30 -0
- package/dist/native/account-common.d.ts.map +1 -0
- package/dist/native/account-common.js +155 -0
- package/dist/native/account-common.js.map +1 -0
- package/dist/native/account-cursor.d.ts +5 -0
- package/dist/native/account-cursor.d.ts.map +1 -0
- package/dist/native/account-cursor.js +48 -0
- package/dist/native/account-cursor.js.map +1 -0
- package/dist/native/account-grok.d.ts +4 -0
- package/dist/native/account-grok.d.ts.map +1 -0
- package/dist/native/account-grok.js +55 -0
- package/dist/native/account-grok.js.map +1 -0
- package/dist/native/account-observation.test.d.ts +2 -0
- package/dist/native/account-observation.test.d.ts.map +1 -0
- package/dist/native/account-observation.test.js +256 -0
- package/dist/native/account-observation.test.js.map +1 -0
- package/dist/native/account-profiles.d.ts +11 -0
- package/dist/native/account-profiles.d.ts.map +1 -0
- package/dist/native/account-profiles.js +172 -0
- package/dist/native/account-profiles.js.map +1 -0
- package/dist/native/account-profiles.test.d.ts +2 -0
- package/dist/native/account-profiles.test.d.ts.map +1 -0
- package/dist/native/account-profiles.test.js +172 -0
- package/dist/native/account-profiles.test.js.map +1 -0
- package/dist/native/account-types.d.ts +11 -0
- package/dist/native/account-types.d.ts.map +1 -0
- package/dist/native/account-types.js +2 -0
- package/dist/native/account-types.js.map +1 -0
- package/dist/native/acp-requests.d.ts +11 -0
- package/dist/native/acp-requests.d.ts.map +1 -0
- package/dist/native/acp-requests.js +109 -0
- package/dist/native/acp-requests.js.map +1 -0
- package/dist/native/acp-rpc.d.ts +15 -0
- package/dist/native/acp-rpc.d.ts.map +1 -0
- package/dist/native/acp-rpc.js +73 -0
- package/dist/native/acp-rpc.js.map +1 -0
- package/dist/native/acp.d.ts +15 -0
- package/dist/native/acp.d.ts.map +1 -0
- package/dist/native/acp.js +285 -0
- package/dist/native/acp.js.map +1 -0
- package/dist/native/acp.test.d.ts +2 -0
- package/dist/native/acp.test.d.ts.map +1 -0
- package/dist/native/acp.test.js +257 -0
- package/dist/native/acp.test.js.map +1 -0
- package/dist/native/affinity.test.d.ts +2 -0
- package/dist/native/affinity.test.d.ts.map +1 -0
- package/dist/native/affinity.test.js +30 -0
- package/dist/native/affinity.test.js.map +1 -0
- package/dist/native/claude.d.ts +11 -0
- package/dist/native/claude.d.ts.map +1 -0
- package/dist/native/claude.js +504 -0
- package/dist/native/claude.js.map +1 -0
- package/dist/native/client.d.ts +34 -0
- package/dist/native/client.d.ts.map +1 -0
- package/dist/native/client.js +217 -0
- package/dist/native/client.js.map +1 -0
- package/dist/native/codex.d.ts +3 -0
- package/dist/native/codex.d.ts.map +1 -0
- package/dist/native/codex.js +324 -0
- package/dist/native/codex.js.map +1 -0
- package/dist/native/cursor-model.d.ts +6 -0
- package/dist/native/cursor-model.d.ts.map +1 -0
- package/dist/native/cursor-model.js +74 -0
- package/dist/native/cursor-model.js.map +1 -0
- package/dist/native/cursor-model.test.d.ts +2 -0
- package/dist/native/cursor-model.test.d.ts.map +1 -0
- package/dist/native/cursor-model.test.js +40 -0
- package/dist/native/cursor-model.test.js.map +1 -0
- package/dist/native/cursor.d.ts +2 -0
- package/dist/native/cursor.d.ts.map +1 -0
- package/dist/native/cursor.js +30 -0
- package/dist/native/cursor.js.map +1 -0
- package/dist/native/durability.test.d.ts +2 -0
- package/dist/native/durability.test.d.ts.map +1 -0
- package/dist/native/durability.test.js +976 -0
- package/dist/native/durability.test.js.map +1 -0
- package/dist/native/filesystem-policy.test.d.ts +2 -0
- package/dist/native/filesystem-policy.test.d.ts.map +1 -0
- package/dist/native/filesystem-policy.test.js +136 -0
- package/dist/native/filesystem-policy.test.js.map +1 -0
- package/dist/native/grok.d.ts +2 -0
- package/dist/native/grok.d.ts.map +1 -0
- package/dist/native/grok.js +17 -0
- package/dist/native/grok.js.map +1 -0
- package/dist/native/host.d.ts +2 -0
- package/dist/native/host.d.ts.map +1 -0
- package/dist/native/host.js +129 -0
- package/dist/native/host.js.map +1 -0
- package/dist/native/index.d.ts +7 -0
- package/dist/native/index.d.ts.map +1 -0
- package/dist/native/index.js +6 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/ipc.d.ts +108 -0
- package/dist/native/ipc.d.ts.map +1 -0
- package/dist/native/ipc.js +181 -0
- package/dist/native/ipc.js.map +1 -0
- package/dist/native/manager.d.ts +61 -0
- package/dist/native/manager.d.ts.map +1 -0
- package/dist/native/manager.js +850 -0
- package/dist/native/manager.js.map +1 -0
- package/dist/native/process-tree.d.ts +26 -0
- package/dist/native/process-tree.d.ts.map +1 -0
- package/dist/native/process-tree.js +231 -0
- package/dist/native/process-tree.js.map +1 -0
- package/dist/native/process-tree.test.d.ts +2 -0
- package/dist/native/process-tree.test.d.ts.map +1 -0
- package/dist/native/process-tree.test.js +174 -0
- package/dist/native/process-tree.test.js.map +1 -0
- package/dist/native/process.d.ts +38 -0
- package/dist/native/process.d.ts.map +1 -0
- package/dist/native/process.js +246 -0
- package/dist/native/process.js.map +1 -0
- package/dist/native/process.test.d.ts +2 -0
- package/dist/native/process.test.d.ts.map +1 -0
- package/dist/native/process.test.js +56 -0
- package/dist/native/process.test.js.map +1 -0
- package/dist/native/profile-service.d.ts +5 -0
- package/dist/native/profile-service.d.ts.map +1 -0
- package/dist/native/profile-service.js +76 -0
- package/dist/native/profile-service.js.map +1 -0
- package/dist/native/registry.d.ts +20 -0
- package/dist/native/registry.d.ts.map +1 -0
- package/dist/native/registry.js +89 -0
- package/dist/native/registry.js.map +1 -0
- package/dist/native/registry.test.d.ts +2 -0
- package/dist/native/registry.test.d.ts.map +1 -0
- package/dist/native/registry.test.js +13 -0
- package/dist/native/registry.test.js.map +1 -0
- package/dist/native/review-sandbox.d.ts +12 -0
- package/dist/native/review-sandbox.d.ts.map +1 -0
- package/dist/native/review-sandbox.js +6 -0
- package/dist/native/review-sandbox.js.map +1 -0
- package/dist/native/review-sandbox.test.d.ts +2 -0
- package/dist/native/review-sandbox.test.d.ts.map +1 -0
- package/dist/native/review-sandbox.test.js +40 -0
- package/dist/native/review-sandbox.test.js.map +1 -0
- package/dist/native/service.d.ts +7 -0
- package/dist/native/service.d.ts.map +1 -0
- package/dist/native/service.js +151 -0
- package/dist/native/service.js.map +1 -0
- package/dist/native/storage.d.ts +11 -0
- package/dist/native/storage.d.ts.map +1 -0
- package/dist/native/storage.js +102 -0
- package/dist/native/storage.js.map +1 -0
- package/dist/native/supervisor.d.ts +2 -0
- package/dist/native/supervisor.d.ts.map +1 -0
- package/dist/native/supervisor.js +215 -0
- package/dist/native/supervisor.js.map +1 -0
- package/dist/native/types.d.ts +36 -0
- package/dist/native/types.d.ts.map +1 -0
- package/dist/native/types.js +2 -0
- package/dist/native/types.js.map +1 -0
- package/dist/native/worker-history.d.ts +16 -0
- package/dist/native/worker-history.d.ts.map +1 -0
- package/dist/native/worker-history.js +77 -0
- package/dist/native/worker-history.js.map +1 -0
- package/dist/native/worker-history.test.d.ts +2 -0
- package/dist/native/worker-history.test.d.ts.map +1 -0
- package/dist/native/worker-history.test.js +195 -0
- package/dist/native/worker-history.test.js.map +1 -0
- package/dist/native/worker-launch.d.ts +72 -0
- package/dist/native/worker-launch.d.ts.map +1 -0
- package/dist/native/worker-launch.js +134 -0
- package/dist/native/worker-launch.js.map +1 -0
- package/dist/native/worker-launch.test.d.ts +2 -0
- package/dist/native/worker-launch.test.d.ts.map +1 -0
- package/dist/native/worker-launch.test.js +61 -0
- package/dist/native/worker-launch.test.js.map +1 -0
- package/dist/native/worker-relocation.test.d.ts +2 -0
- package/dist/native/worker-relocation.test.d.ts.map +1 -0
- package/dist/native/worker-relocation.test.js +110 -0
- package/dist/native/worker-relocation.test.js.map +1 -0
- package/dist/native/worker-service.d.ts +4 -0
- package/dist/native/worker-service.d.ts.map +1 -0
- package/dist/native/worker-service.js +85 -0
- package/dist/native/worker-service.js.map +1 -0
- package/dist/native/workspace-files.d.ts +6 -0
- package/dist/native/workspace-files.d.ts.map +1 -0
- package/dist/native/workspace-files.js +62 -0
- package/dist/native/workspace-files.js.map +1 -0
- package/dist/native/workspace.d.ts +5 -0
- package/dist/native/workspace.d.ts.map +1 -0
- package/dist/native/workspace.js +120 -0
- package/dist/native/workspace.js.map +1 -0
- package/dist/task-init/index.d.ts +70 -0
- package/dist/task-init/index.d.ts.map +1 -0
- package/dist/task-init/index.js +124 -0
- package/dist/task-init/index.js.map +1 -0
- package/dist/task-init/task-document.d.ts +98 -0
- package/dist/task-init/task-document.d.ts.map +1 -0
- package/dist/task-init/task-document.js +184 -0
- package/dist/task-init/task-document.js.map +1 -0
- package/dist/task-init/write.d.ts +42 -0
- package/dist/task-init/write.d.ts.map +1 -0
- package/dist/task-init/write.js +63 -0
- package/dist/task-init/write.js.map +1 -0
- package/package.json +13 -4
- package/scripts/checklist-target.cjs +5 -3
- package/scripts/cursor-session-discovery.cjs +48 -0
- package/scripts/execution-template-cli.mjs +17 -18
- package/scripts/mark-checklist-step.cjs +1 -1
- package/scripts/review-filesystem.cjs +39 -0
- package/scripts/review-session.cjs +45 -0
- package/scripts/review-terminal.cjs +134 -0
- package/scripts/task-init-cli.mjs +166 -0
- package/scripts/worker-terminal-contract.cjs +15 -3
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import fs, { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
6
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { delimiter, join } from 'node:path';
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import { NativeSessionClient } from './client.js';
|
|
12
|
+
import { decodeRequest, requestHost, socketDirectory } from './ipc.js';
|
|
13
|
+
import { NativeSessionManager } from './manager.js';
|
|
14
|
+
import { alive, appendDurable, matchesProcess, privateDirectory, readJson } from './storage.js';
|
|
15
|
+
import { decodeNativeWorkerLaunch } from './worker-launch.js';
|
|
16
|
+
const fixture = `#!/usr/bin/env node
|
|
17
|
+
if (process.argv.includes('--version')) { console.log('fixture 1'); process.exit(0); }
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const send = m => process.stdout.write(JSON.stringify(m)+'\\n');
|
|
20
|
+
let thread, turn, timer;
|
|
21
|
+
require('node:readline').createInterface({input:process.stdin}).on('line', line => {
|
|
22
|
+
const m = JSON.parse(line);
|
|
23
|
+
if(m.method==='initialize') send({id:m.id,result:{}});
|
|
24
|
+
if(m.method==='thread/start'||m.method==='thread/resume') {
|
|
25
|
+
fs.writeFileSync('native-start.json',JSON.stringify({params:m.params,env:{probe:process.env.NATIVE_WORKER_PROBE,omc:process.env.DISABLE_OMC,omx:process.env.DISABLE_OMX}}),{mode:0o600});
|
|
26
|
+
thread=m.params.threadId || 'thread-'+process.pid;
|
|
27
|
+
send({id:m.id,result:{thread:{id:thread}}});
|
|
28
|
+
}
|
|
29
|
+
if(m.method==='turn/start') {
|
|
30
|
+
fs.writeFileSync('native-turn.json',JSON.stringify(m.params),{mode:0o600});
|
|
31
|
+
const text=m.params.input[0].text; turn='turn-'+m.id;
|
|
32
|
+
fs.appendFileSync('inputs',text+'\\n');
|
|
33
|
+
if(text==='unknown') { process.exit(23); }
|
|
34
|
+
send({id:m.id,result:{turn:{id:turn}}});
|
|
35
|
+
send({method:'turn/started',params:{threadId:thread,turn:{id:turn}}});
|
|
36
|
+
if(text==='approval') {send({id:7,method:'item/commandExecution/requestApproval',params:{threadId:thread,command:'fixture tool'}});return;}
|
|
37
|
+
send({method:'item/started',params:{threadId:thread,item:{id:'tool',type:'commandExecution',command:'fixture tool'}}});
|
|
38
|
+
const child = require('node:child_process').spawn(process.execPath,['-e',"setInterval(()=>{},1000)"],{stdio:'ignore',detached:true});
|
|
39
|
+
fs.writeFileSync('descendant',String(child.pid));
|
|
40
|
+
timer=setTimeout(()=>{
|
|
41
|
+
child.kill(); fs.appendFileSync('effects',text+'\\n');
|
|
42
|
+
for(let i=0;i<300;i++) send({method:'item/agentMessage/delta',params:{threadId:thread,itemId:'text',delta:'x'}});
|
|
43
|
+
send({method:'turn/completed',params:{threadId:thread,turn:{id:turn,status:'completed'}}});
|
|
44
|
+
},600);
|
|
45
|
+
}
|
|
46
|
+
if(m.id===7 && !m.method){fs.appendFileSync('decisions',JSON.stringify(m.result)+'\\n');send({method:'turn/completed',params:{threadId:thread,turn:{id:turn,status:'completed'}}});}
|
|
47
|
+
if(m.method==='turn/interrupt'){clearTimeout(timer);send({id:m.id,result:{}});send({method:'turn/completed',params:{threadId:thread,turn:{id:turn,status:'interrupted'}}});}
|
|
48
|
+
});`;
|
|
49
|
+
const delay = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
async function until(predicate) {
|
|
51
|
+
const deadline = Date.now() + 12_000;
|
|
52
|
+
while (!(await predicate())) {
|
|
53
|
+
if (Date.now() > deadline)
|
|
54
|
+
throw new Error('Test condition timed out');
|
|
55
|
+
await delay();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function setup() {
|
|
59
|
+
const cwd = mkdtempSync(join(tmpdir(), 'native-durability-'));
|
|
60
|
+
writeFileSync(join(cwd, 'codex'), fixture);
|
|
61
|
+
chmodSync(join(cwd, 'codex'), 0o700);
|
|
62
|
+
const oldPath = process.env.PATH;
|
|
63
|
+
process.env.PATH = `${cwd}${delimiter}${oldPath}`;
|
|
64
|
+
return {
|
|
65
|
+
cwd,
|
|
66
|
+
root: join(cwd, 'state'),
|
|
67
|
+
restore() {
|
|
68
|
+
if (oldPath === undefined)
|
|
69
|
+
delete process.env.PATH;
|
|
70
|
+
else
|
|
71
|
+
process.env.PATH = oldPath;
|
|
72
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
for (const mode of ['startup', 'periodic', 'persistent']) {
|
|
77
|
+
test(`supervisor ${mode} census failure stops its host and preserves cleanup truth`, async () => {
|
|
78
|
+
const fixture = setup();
|
|
79
|
+
const client = new NativeSessionClient(fixture.root);
|
|
80
|
+
const config = join(fixture.cwd, 'census-fault.json');
|
|
81
|
+
const previousOptions = process.env.NODE_OPTIONS;
|
|
82
|
+
const previousFault = process.env.FARMSLOT_NATIVE_CENSUS_FAULT;
|
|
83
|
+
const preload = new URL('../../../../scripts/runner-validation/fixtures/native-census-fault.mjs', import.meta.url).href;
|
|
84
|
+
process.env.NODE_OPTIONS = `${previousOptions ?? ''} --import=${preload}`.trim();
|
|
85
|
+
process.env.FARMSLOT_NATIVE_CENSUS_FAULT = config;
|
|
86
|
+
writeFileSync(config, JSON.stringify({ root: fixture.root, armed: mode === 'startup' }), {
|
|
87
|
+
mode: 0o600,
|
|
88
|
+
});
|
|
89
|
+
writeFileSync(join(fixture.cwd, 'codex'), readFileSync(join(fixture.cwd, 'codex'), 'utf8').replace('},600);', '},60000);'));
|
|
90
|
+
let descendant;
|
|
91
|
+
let descendantStart;
|
|
92
|
+
try {
|
|
93
|
+
if (mode === 'startup') {
|
|
94
|
+
await assert.rejects(client.create('owner', { runner: 'codex', cwd: fixture.cwd }), /Native host unavailable/);
|
|
95
|
+
const host = readJson(join(fixture.root, 'host.json'));
|
|
96
|
+
const worker = readJson(join(fixture.root, 'worker.json'));
|
|
97
|
+
assert.equal(alive(host.pid), false);
|
|
98
|
+
assert.equal(alive(worker.pid), false);
|
|
99
|
+
assert.equal(readJson(join(fixture.root, 'cleanup.json')).state, 'complete');
|
|
100
|
+
const retry = await client.create('owner', { runner: 'codex', cwd: fixture.cwd });
|
|
101
|
+
await client.close('owner', retry.id);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const session = await client.create('owner', { runner: 'codex', cwd: fixture.cwd });
|
|
105
|
+
const host = readJson(join(fixture.root, 'host.json'));
|
|
106
|
+
await client.send('owner', session.id, 'census', 'crash');
|
|
107
|
+
await until(() => existsSync(join(fixture.cwd, 'descendant')));
|
|
108
|
+
descendant = Number(readFileSync(join(fixture.cwd, 'descendant'), 'utf8'));
|
|
109
|
+
descendantStart = execFileSync('ps', ['-p', String(descendant), '-o', 'lstart='], {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
}).trim();
|
|
112
|
+
writeFileSync(config, JSON.stringify({ root: fixture.root, armed: false, observePid: descendant }));
|
|
113
|
+
await until(() => existsSync(`${config}.observed`));
|
|
114
|
+
writeFileSync(config, JSON.stringify({ root: fixture.root, armed: true, persistent: mode === 'persistent' }));
|
|
115
|
+
await until(() => existsSync(`${config}.fired`) && !alive(host.pid) && !alive(session.hostPid));
|
|
116
|
+
await until(() => !alive(session.processPid));
|
|
117
|
+
const cleanup = readJson(join(fixture.root, 'cleanup.json'));
|
|
118
|
+
assert.equal(cleanup.state, mode === 'persistent' ? 'unknown' : 'complete');
|
|
119
|
+
assert.match(cleanup.error ?? '', /census timeout/);
|
|
120
|
+
assert.equal(existsSync(join(fixture.cwd, 'effects')), false);
|
|
121
|
+
if (mode === 'persistent') {
|
|
122
|
+
assert.ok(cleanup.observedProcesses?.some((entry) => entry.pid === descendant));
|
|
123
|
+
await assert.rejects(client.list('owner'), /Native host unavailable/);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
assert.equal(alive(descendant), false);
|
|
127
|
+
const recovered = await client.read('owner', session.id);
|
|
128
|
+
assert.equal(recovered.session.state, 'failed');
|
|
129
|
+
assert.equal(recovered.session.processStopped, true);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
rmSync(config, { force: true });
|
|
135
|
+
const hostFile = join(fixture.root, 'host.json');
|
|
136
|
+
if (existsSync(hostFile)) {
|
|
137
|
+
const host = readJson(hostFile);
|
|
138
|
+
if (alive(host.pid)) {
|
|
139
|
+
process.kill(host.pid, 'SIGTERM');
|
|
140
|
+
await until(() => !alive(host.pid));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (descendant && alive(descendant)) {
|
|
144
|
+
const ownedDescendant = descendant;
|
|
145
|
+
const current = execFileSync('ps', ['-p', String(ownedDescendant), '-o', 'lstart='], {
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
}).trim();
|
|
148
|
+
assert.equal(current, descendantStart, 'Fixture descendant PID was reused');
|
|
149
|
+
process.kill(ownedDescendant, 'SIGKILL');
|
|
150
|
+
await until(() => !alive(ownedDescendant));
|
|
151
|
+
}
|
|
152
|
+
if (previousOptions === undefined)
|
|
153
|
+
delete process.env.NODE_OPTIONS;
|
|
154
|
+
else
|
|
155
|
+
process.env.NODE_OPTIONS = previousOptions;
|
|
156
|
+
if (previousFault === undefined)
|
|
157
|
+
delete process.env.FARMSLOT_NATIVE_CENSUS_FAULT;
|
|
158
|
+
else
|
|
159
|
+
process.env.FARMSLOT_NATIVE_CENSUS_FAULT = previousFault;
|
|
160
|
+
rmSync(socketDirectory(fixture.root), { recursive: true, force: true });
|
|
161
|
+
fixture.restore();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
test('reserved native creation is concurrent-safe and survives journal reload without relaunch', async () => {
|
|
166
|
+
const fixture = setup();
|
|
167
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
168
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
169
|
+
try {
|
|
170
|
+
const [first, second] = await Promise.all([
|
|
171
|
+
manager.ensure('owner', params),
|
|
172
|
+
manager.ensure('owner', params),
|
|
173
|
+
]);
|
|
174
|
+
assert.equal(first.id, params.sessionId);
|
|
175
|
+
assert.equal(second.id, first.id);
|
|
176
|
+
assert.equal(second.generation, first.generation);
|
|
177
|
+
assert.equal(second.processPid, first.processPid);
|
|
178
|
+
assert.equal(manager.list('owner').length, 1);
|
|
179
|
+
await assert.rejects(manager.ensure('another-owner', params), /another owner/);
|
|
180
|
+
await assert.rejects(manager.ensure('owner', { ...params, model: 'changed' }), /launch configuration differs: model/);
|
|
181
|
+
await assert.rejects(manager.ensure('owner', { ...params, sessionId: '../escape' }), /must be a lowercase UUID/);
|
|
182
|
+
await assert.rejects(manager.ensure('owner', { ...params, sessionId: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA' }), /lowercase UUID/);
|
|
183
|
+
const invalidResume = { ...params, resumeSessionId: first.nativeSessionId };
|
|
184
|
+
await assert.rejects(manager.ensure('owner', invalidResume), /not resume/);
|
|
185
|
+
for (const resumeSessionId of ['', null, first.nativeSessionId])
|
|
186
|
+
assert.throws(() => decodeRequest({
|
|
187
|
+
method: 'ensure',
|
|
188
|
+
owner: 'owner',
|
|
189
|
+
params: { ...params, resumeSessionId },
|
|
190
|
+
}), /Reserved sessionId is not for resume/);
|
|
191
|
+
await manager.close('owner', first.id);
|
|
192
|
+
const reloaded = new NativeSessionManager(fixture.root);
|
|
193
|
+
const retried = await reloaded.ensure('owner', params);
|
|
194
|
+
assert.equal(retried.id, first.id);
|
|
195
|
+
assert.equal(retried.generation, first.generation);
|
|
196
|
+
assert.equal(retried.state, 'closed');
|
|
197
|
+
assert.equal(retried.processStopped, true);
|
|
198
|
+
assert.equal(alive(retried.processPid), false);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
for (const session of manager.list('owner'))
|
|
202
|
+
await manager.close('owner', session.id);
|
|
203
|
+
fixture.restore();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test('failed initial reservation remains an error on retry and never launches a process', async () => {
|
|
207
|
+
const fixture = setup();
|
|
208
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
209
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
210
|
+
const journal = join(fixture.root, `${params.sessionId}.journal`);
|
|
211
|
+
// A real filesystem refusal before any runner launch. Repairing the path must not
|
|
212
|
+
// turn the same host's uncertain reservation into a successful starting session.
|
|
213
|
+
mkdirSync(journal);
|
|
214
|
+
try {
|
|
215
|
+
await assert.rejects(manager.ensure('owner', params), { code: 'EISDIR' });
|
|
216
|
+
rmSync(journal, { recursive: true });
|
|
217
|
+
await assert.rejects(manager.ensure('owner', params), { code: 'EISDIR' });
|
|
218
|
+
const failed = manager.list('owner')[0];
|
|
219
|
+
assert.equal(failed.state, 'failed');
|
|
220
|
+
assert.equal(failed.processPid, undefined);
|
|
221
|
+
assert.equal(failed.nativeSessionId, '');
|
|
222
|
+
assert.equal(existsSync(journal), false);
|
|
223
|
+
const reloaded = new NativeSessionManager(fixture.root);
|
|
224
|
+
assert.equal(reloaded.list('owner').length, 0, 'A failed open wrote no durable reservation');
|
|
225
|
+
const created = await reloaded.ensure('owner', params);
|
|
226
|
+
assert.equal(created.id, params.sessionId);
|
|
227
|
+
assert.equal(created.state, 'idle');
|
|
228
|
+
await reloaded.close('owner', created.id);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
for (const session of manager.list('owner'))
|
|
232
|
+
await manager.close('owner', session.id);
|
|
233
|
+
fixture.restore();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
test('an uncertain reservation fsync remains failed after retry and journal reload', async (t) => {
|
|
237
|
+
const fixture = setup();
|
|
238
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
239
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
240
|
+
const fault = t.mock.method(fs, 'fsyncSync', () => {
|
|
241
|
+
throw Object.assign(new Error('Reservation fsync failed'), { code: 'EIO' });
|
|
242
|
+
});
|
|
243
|
+
syncBuiltinESMExports();
|
|
244
|
+
try {
|
|
245
|
+
await assert.rejects(manager.ensure('owner', params), { code: 'EIO' });
|
|
246
|
+
fault.mock.restore();
|
|
247
|
+
syncBuiltinESMExports();
|
|
248
|
+
assert.ok(existsSync(join(fixture.root, `${params.sessionId}.journal`)));
|
|
249
|
+
await assert.rejects(manager.ensure('owner', params), { code: 'EIO' });
|
|
250
|
+
const failed = manager.list('owner')[0];
|
|
251
|
+
assert.equal(failed.state, 'failed');
|
|
252
|
+
assert.equal(failed.processPid, undefined);
|
|
253
|
+
const reloaded = new NativeSessionManager(fixture.root);
|
|
254
|
+
const retried = await reloaded.ensure('owner', params);
|
|
255
|
+
assert.equal(retried.id, params.sessionId);
|
|
256
|
+
assert.equal(retried.generation, failed.generation);
|
|
257
|
+
assert.equal(retried.state, 'failed');
|
|
258
|
+
assert.equal(retried.processPid, undefined);
|
|
259
|
+
assert.equal(retried.nativeSessionId, '');
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
fault.mock.restore();
|
|
263
|
+
syncBuiltinESMExports();
|
|
264
|
+
fixture.restore();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test('worker launch keeps environment private and transfers only an idle task lease', async () => {
|
|
268
|
+
const fixture = setup();
|
|
269
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
270
|
+
const secret = `private-environment-${randomUUID()}`;
|
|
271
|
+
const launch = {
|
|
272
|
+
leaseId: randomUUID(),
|
|
273
|
+
executable: './codex',
|
|
274
|
+
effort: 'high',
|
|
275
|
+
safetyTier: 'full-auto',
|
|
276
|
+
environment: { set: { NATIVE_WORKER_PROBE: secret }, unset: [] },
|
|
277
|
+
};
|
|
278
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
279
|
+
try {
|
|
280
|
+
assert.throws(() => decodeNativeWorkerLaunch({ ...launch, futurePermissionFlag: true }), /Unsupported/);
|
|
281
|
+
const session = await manager.ensure('owner', params, launch);
|
|
282
|
+
assert.equal(session.workerManaged, true);
|
|
283
|
+
assert.equal(session.workerLeaseId, launch.leaseId);
|
|
284
|
+
const started = readJson(join(fixture.cwd, 'native-start.json'));
|
|
285
|
+
assert.deepEqual(started.env, { probe: secret, omc: '1', omx: '1' });
|
|
286
|
+
assert.equal(started.params.approvalPolicy, 'never');
|
|
287
|
+
assert.equal(started.params.sandbox, 'workspace-write');
|
|
288
|
+
const next = { ...launch, leaseId: randomUUID() };
|
|
289
|
+
await manager.send('owner', session.id, randomUUID(), 'approval');
|
|
290
|
+
assert.equal(readJson(join(fixture.cwd, 'native-turn.json')).effort, 'high');
|
|
291
|
+
await until(() => manager.read('owner', session.id).pendingRequests.length > 0);
|
|
292
|
+
assert.throws(() => manager.transferWorker('owner', session.id, session.generation, launch.leaseId, next), /idle session/);
|
|
293
|
+
await manager.respond('owner', session.id, manager.read('owner', session.id).pendingRequests[0].request.id, { decision: 'deny' });
|
|
294
|
+
await until(() => manager.read('owner', session.id).session.state === 'idle');
|
|
295
|
+
assert.throws(() => manager.transferWorker('owner', session.id, session.generation, launch.leaseId, {
|
|
296
|
+
...next,
|
|
297
|
+
safetyTier: 'dangerous',
|
|
298
|
+
}), /preserve generation and launch configuration/);
|
|
299
|
+
const handedOff = manager.transferWorker('owner', session.id, session.generation, launch.leaseId, next);
|
|
300
|
+
assert.equal(handedOff.workerLeaseId, next.leaseId);
|
|
301
|
+
assert.equal(handedOff.processPid, session.processPid);
|
|
302
|
+
assert.equal(manager.transferWorker('owner', session.id, session.generation, launch.leaseId, next)
|
|
303
|
+
.workerLeaseId, next.leaseId);
|
|
304
|
+
assert.throws(() => manager.assertWorkerLease('owner', session.id, session.generation, launch.leaseId), /task lease changed/);
|
|
305
|
+
await assert.rejects(manager.ensure('owner', params, launch), /another worker task lease/);
|
|
306
|
+
manager.assertWorkerLease('owner', session.id, session.generation, next.leaseId);
|
|
307
|
+
assert.equal((await manager.ensure('owner', params, next)).processPid, session.processPid);
|
|
308
|
+
assert.equal(readFileSync(join(fixture.root, `${session.id}.journal`), 'utf8').includes(secret), false);
|
|
309
|
+
await manager.close('owner', session.id);
|
|
310
|
+
await assert.rejects(manager.create('owner', {
|
|
311
|
+
runner: 'codex',
|
|
312
|
+
cwd: fixture.cwd,
|
|
313
|
+
resumeSessionId: session.nativeSessionId,
|
|
314
|
+
}), /Worker recovery requires/);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
for (const session of manager.list('owner'))
|
|
318
|
+
await manager.close('owner', session.id);
|
|
319
|
+
fixture.restore();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
test('uncertain worker lease persistence refuses input until explicit close and recovery', async (t) => {
|
|
323
|
+
const fixture = setup();
|
|
324
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
325
|
+
const launch = {
|
|
326
|
+
leaseId: randomUUID(),
|
|
327
|
+
executable: join(fixture.cwd, 'codex'),
|
|
328
|
+
safetyTier: 'sandboxed',
|
|
329
|
+
environment: { set: {}, unset: [] },
|
|
330
|
+
};
|
|
331
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
332
|
+
const session = await manager.ensure('owner', params, launch);
|
|
333
|
+
const next = { ...launch, leaseId: randomUUID() };
|
|
334
|
+
const fault = t.mock.method(fs, 'fsyncSync', () => {
|
|
335
|
+
throw new Error('Lease flush failed');
|
|
336
|
+
});
|
|
337
|
+
syncBuiltinESMExports();
|
|
338
|
+
try {
|
|
339
|
+
assert.throws(() => manager.transferWorker('owner', session.id, session.generation, launch.leaseId, next), /Lease flush failed/);
|
|
340
|
+
fault.mock.restore();
|
|
341
|
+
syncBuiltinESMExports();
|
|
342
|
+
assert.throws(() => manager.assertWorkerLease('owner', session.id, session.generation, next.leaseId), /Lease flush failed/);
|
|
343
|
+
assert.throws(() => manager.transferWorker('owner', session.id, session.generation, launch.leaseId, next), /Lease flush failed/);
|
|
344
|
+
await assert.rejects(manager.ensure('owner', params, next), /Lease flush failed/);
|
|
345
|
+
manager.assertWorkerLease('owner', session.id, session.generation, next.leaseId, true);
|
|
346
|
+
await manager.close('owner', session.id);
|
|
347
|
+
const reloaded = new NativeSessionManager(fixture.root);
|
|
348
|
+
const closed = await reloaded.ensure('owner', params, next);
|
|
349
|
+
assert.equal(closed.state, 'closed');
|
|
350
|
+
assert.equal(closed.workerLeaseId, next.leaseId);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
fault.mock.restore();
|
|
354
|
+
syncBuiltinESMExports();
|
|
355
|
+
for (const session of manager.list('owner'))
|
|
356
|
+
await manager.close('owner', session.id);
|
|
357
|
+
fixture.restore();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
test('cancelling an initial worker reservation prevents a delayed launch and survives reload', async () => {
|
|
361
|
+
const fixture = setup();
|
|
362
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
363
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
364
|
+
const launch = {
|
|
365
|
+
leaseId: randomUUID(),
|
|
366
|
+
executable: join(fixture.cwd, 'codex'),
|
|
367
|
+
safetyTier: 'sandboxed',
|
|
368
|
+
environment: { set: {}, unset: [] },
|
|
369
|
+
};
|
|
370
|
+
const pending = manager.ensure('owner', params, launch);
|
|
371
|
+
try {
|
|
372
|
+
assert.equal((await manager.cancelWorker('owner', params.sessionId, launch.leaseId)).cancelled, true);
|
|
373
|
+
await assert.rejects(pending, /cancelled before launch/);
|
|
374
|
+
assert.equal(existsSync(join(fixture.cwd, 'native-start.json')), false);
|
|
375
|
+
assert.equal(manager.list('owner').length, 0);
|
|
376
|
+
const reloaded = new NativeSessionManager(fixture.root);
|
|
377
|
+
await assert.rejects(reloaded.ensure('owner', params, launch), /cancelled before launch/);
|
|
378
|
+
await assert.rejects(reloaded.cancelWorker('other', params.sessionId, launch.leaseId), /another owner/);
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
await Promise.allSettled([pending]);
|
|
382
|
+
for (const session of manager.list('owner'))
|
|
383
|
+
await manager.close('owner', session.id);
|
|
384
|
+
fixture.restore();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
test('cancelling a reserved handoff fences a delayed transfer and preserves later task leases', async () => {
|
|
388
|
+
const fixture = setup();
|
|
389
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
390
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
391
|
+
const launch = {
|
|
392
|
+
leaseId: randomUUID(),
|
|
393
|
+
executable: join(fixture.cwd, 'codex'),
|
|
394
|
+
safetyTier: 'full-auto',
|
|
395
|
+
environment: { set: {}, unset: [] },
|
|
396
|
+
};
|
|
397
|
+
const successor = { ...launch, leaseId: randomUUID() };
|
|
398
|
+
try {
|
|
399
|
+
const session = await manager.ensure('owner', params, launch);
|
|
400
|
+
await assert.rejects(manager.cancelWorker('other', session.id, successor.leaseId, session.generation, launch.leaseId), /ownership changed/);
|
|
401
|
+
assert.equal(manager.read('owner', session.id).session.workerLeaseId, launch.leaseId);
|
|
402
|
+
const cancel = manager.cancelWorker('owner', session.id, successor.leaseId, session.generation, launch.leaseId);
|
|
403
|
+
const delayed = manager.transferWorker('owner', session.id, session.generation, launch.leaseId, successor);
|
|
404
|
+
assert.equal(delayed.workerLeaseId, successor.leaseId);
|
|
405
|
+
const stopped = await cancel;
|
|
406
|
+
assert.equal(stopped.session?.processStopped, true);
|
|
407
|
+
assert.equal(stopped.session?.state, 'closed');
|
|
408
|
+
const retried = await manager.ensure('owner', params, successor);
|
|
409
|
+
assert.equal(retried.state, 'closed');
|
|
410
|
+
assert.equal(retried.processPid, session.processPid);
|
|
411
|
+
assert.equal((await new NativeSessionManager(fixture.root).ensure('owner', params, successor)).state, 'closed');
|
|
412
|
+
const resumed = await manager.create('owner', { ...params, resumeSessionId: session.nativeSessionId }, successor);
|
|
413
|
+
const later = { ...successor, leaseId: randomUUID() };
|
|
414
|
+
manager.transferWorker('owner', session.id, resumed.generation, successor.leaseId, later);
|
|
415
|
+
await assert.rejects(manager.cancelWorker('owner', session.id, successor.leaseId, resumed.generation, launch.leaseId), /ownership changed/);
|
|
416
|
+
assert.equal(manager.read('owner', session.id).session.workerLeaseId, later.leaseId);
|
|
417
|
+
assert.ok(alive(resumed.processPid));
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
for (const session of manager.list('owner'))
|
|
421
|
+
await manager.close('owner', session.id);
|
|
422
|
+
fixture.restore();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
test('cancelled recovery IDs survive reload while a new explicit recovery remains available', async () => {
|
|
426
|
+
const fixture = setup();
|
|
427
|
+
let manager = new NativeSessionManager(fixture.root);
|
|
428
|
+
const launch = {
|
|
429
|
+
leaseId: randomUUID(),
|
|
430
|
+
executable: join(fixture.cwd, 'codex'),
|
|
431
|
+
safetyTier: 'sandboxed',
|
|
432
|
+
environment: { set: {}, unset: [] },
|
|
433
|
+
};
|
|
434
|
+
try {
|
|
435
|
+
const first = await manager.ensure('owner', { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd }, launch);
|
|
436
|
+
await manager.closeWorker('owner', first.id, first.generation, launch.leaseId);
|
|
437
|
+
const params = {
|
|
438
|
+
sessionId: first.id,
|
|
439
|
+
generation: first.generation,
|
|
440
|
+
runner: 'codex',
|
|
441
|
+
cwd: fixture.cwd,
|
|
442
|
+
resumeSessionId: first.nativeSessionId,
|
|
443
|
+
};
|
|
444
|
+
const cancelledIds = [randomUUID(), randomUUID()];
|
|
445
|
+
for (const commandId of cancelledIds)
|
|
446
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId, first.generation, undefined, commandId);
|
|
447
|
+
manager = new NativeSessionManager(fixture.root);
|
|
448
|
+
for (const commandId of cancelledIds)
|
|
449
|
+
await assert.rejects(manager.resumeWorker('owner', { ...params, commandId }, launch), /operation was cancelled/);
|
|
450
|
+
const resumed = await manager.resumeWorker('owner', { ...params, commandId: randomUUID() }, launch);
|
|
451
|
+
assert.notEqual(resumed.generation, first.generation);
|
|
452
|
+
for (const commandId of cancelledIds) {
|
|
453
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId, first.generation, undefined, commandId);
|
|
454
|
+
assert.equal(manager.read('owner', first.id).session.generation, resumed.generation);
|
|
455
|
+
assert.ok(alive(resumed.processPid));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
for (const session of manager.list('owner'))
|
|
460
|
+
await manager.close('owner', session.id);
|
|
461
|
+
fixture.restore();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
for (const race of ['cancel', 'transfer']) {
|
|
465
|
+
test(`worker resume rechecks ${race} after its asynchronous executable probe`, async () => {
|
|
466
|
+
const fixture = setup();
|
|
467
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
468
|
+
const launch = {
|
|
469
|
+
leaseId: randomUUID(),
|
|
470
|
+
executable: join(fixture.cwd, 'codex'),
|
|
471
|
+
safetyTier: 'sandboxed',
|
|
472
|
+
environment: { set: {}, unset: [] },
|
|
473
|
+
};
|
|
474
|
+
let pending;
|
|
475
|
+
try {
|
|
476
|
+
const first = await manager.ensure('owner', { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd }, launch);
|
|
477
|
+
await manager.closeWorker('owner', first.id, first.generation, launch.leaseId);
|
|
478
|
+
const startReceipt = readFileSync(join(fixture.cwd, 'native-start.json'), 'utf8');
|
|
479
|
+
const binary = readFileSync(launch.executable, 'utf8');
|
|
480
|
+
writeFileSync(launch.executable, binary.replace("if (process.argv.includes('--version')) {", `if (process.argv.includes('--version')) {
|
|
481
|
+
const probeFs = require('node:fs');
|
|
482
|
+
probeFs.writeFileSync('version-entered', '');
|
|
483
|
+
while (!probeFs.existsSync('version-release')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
484
|
+
`));
|
|
485
|
+
const commandId = randomUUID();
|
|
486
|
+
pending = manager.resumeWorker('owner', {
|
|
487
|
+
sessionId: first.id,
|
|
488
|
+
generation: first.generation,
|
|
489
|
+
runner: 'codex',
|
|
490
|
+
cwd: fixture.cwd,
|
|
491
|
+
resumeSessionId: first.nativeSessionId,
|
|
492
|
+
commandId,
|
|
493
|
+
}, launch);
|
|
494
|
+
const rejected = assert.rejects(pending, race === 'cancel' ? /operation was cancelled/ : /lease changed/);
|
|
495
|
+
await until(() => existsSync(join(fixture.cwd, 'version-entered')));
|
|
496
|
+
if (race === 'cancel')
|
|
497
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId, first.generation, undefined, commandId);
|
|
498
|
+
else
|
|
499
|
+
manager.transferWorker('owner', first.id, first.generation, launch.leaseId, {
|
|
500
|
+
...launch,
|
|
501
|
+
leaseId: randomUUID(),
|
|
502
|
+
});
|
|
503
|
+
writeFileSync(join(fixture.cwd, 'version-release'), '');
|
|
504
|
+
await rejected;
|
|
505
|
+
const after = manager.read('owner', first.id).session;
|
|
506
|
+
assert.equal(after.generation, first.generation);
|
|
507
|
+
assert.equal(after.processStopped, true);
|
|
508
|
+
assert.equal(readFileSync(join(fixture.cwd, 'native-start.json'), 'utf8'), startReceipt);
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
writeFileSync(join(fixture.cwd, 'version-release'), '');
|
|
512
|
+
if (pending)
|
|
513
|
+
await Promise.allSettled([pending]);
|
|
514
|
+
for (const session of manager.list('owner'))
|
|
515
|
+
await manager.close('owner', session.id);
|
|
516
|
+
fixture.restore();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
test('replayed worker cancellation cannot stop an explicitly resumed generation', async () => {
|
|
521
|
+
const fixture = setup();
|
|
522
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
523
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
524
|
+
const launch = {
|
|
525
|
+
leaseId: randomUUID(),
|
|
526
|
+
executable: join(fixture.cwd, 'codex'),
|
|
527
|
+
safetyTier: 'sandboxed',
|
|
528
|
+
environment: { set: {}, unset: [] },
|
|
529
|
+
};
|
|
530
|
+
try {
|
|
531
|
+
const first = await manager.ensure('owner', params, launch);
|
|
532
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId);
|
|
533
|
+
const resumed = await manager.create('owner', { ...params, resumeSessionId: first.nativeSessionId }, launch);
|
|
534
|
+
assert.notEqual(resumed.generation, first.generation);
|
|
535
|
+
for (const generation of [undefined, first.generation]) {
|
|
536
|
+
const replay = await manager.cancelWorker('owner', first.id, launch.leaseId, generation);
|
|
537
|
+
assert.equal(replay.generation, first.generation);
|
|
538
|
+
assert.equal(manager.read('owner', first.id).session.state, 'idle');
|
|
539
|
+
assert.ok(alive(resumed.processPid));
|
|
540
|
+
}
|
|
541
|
+
const closed = await manager.cancelWorker('owner', first.id, launch.leaseId, resumed.generation);
|
|
542
|
+
assert.equal(closed.session?.state, 'closed');
|
|
543
|
+
assert.equal(closed.session?.processStopped, true);
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
for (const session of manager.list('owner'))
|
|
547
|
+
await manager.close('owner', session.id);
|
|
548
|
+
fixture.restore();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
test('worker host rejects public input and accepts only leased commands and permission replies', async () => {
|
|
552
|
+
const fixture = setup();
|
|
553
|
+
const client = new NativeSessionClient(fixture.root);
|
|
554
|
+
const launch = {
|
|
555
|
+
leaseId: randomUUID(),
|
|
556
|
+
executable: join(fixture.cwd, 'codex'),
|
|
557
|
+
safetyTier: 'sandboxed',
|
|
558
|
+
environment: { set: {}, unset: [] },
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
const session = await client.ensureWorker('owner', { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd }, launch);
|
|
562
|
+
const target = {
|
|
563
|
+
sessionId: session.id,
|
|
564
|
+
generation: session.generation,
|
|
565
|
+
leaseId: launch.leaseId,
|
|
566
|
+
};
|
|
567
|
+
await assert.rejects(client.send('owner', session.id, 'unleased', 'approval'), /requires its run context/);
|
|
568
|
+
await assert.rejects(client.interrupt('owner', session.id), /requires its run context/);
|
|
569
|
+
await assert.rejects(client.close('owner', session.id), /requires its run context/);
|
|
570
|
+
await assert.rejects(client.sendWorker('owner', { ...target, leaseId: randomUUID() }, 'stale', 'approval'), /lease changed/);
|
|
571
|
+
assert.equal(existsSync(join(fixture.cwd, 'inputs')), false);
|
|
572
|
+
await client.sendWorker('owner', target, 'leased-approval', 'approval');
|
|
573
|
+
await until(async () => (await client.read('owner', session.id)).pendingRequests.length === 1);
|
|
574
|
+
const pending = (await client.read('owner', session.id)).pendingRequests[0].request.id;
|
|
575
|
+
await assert.rejects(client.respond('owner', session.id, pending, { decision: 'approve' }), /requires its run context/);
|
|
576
|
+
await assert.rejects(client.respondWorker('owner', { ...target, generation: randomUUID() }, pending, {
|
|
577
|
+
decision: 'approve',
|
|
578
|
+
}), /generation.*changed/);
|
|
579
|
+
assert.equal((await client.read('owner', session.id)).pendingRequests.length, 1);
|
|
580
|
+
await client.respondWorker('owner', target, pending, { decision: 'deny' });
|
|
581
|
+
await until(async () => (await client.read('owner', session.id)).session.state === 'idle');
|
|
582
|
+
assert.equal((await client.closeWorker('owner', target)).processStopped, true);
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
const file = join(fixture.root, 'host.json');
|
|
586
|
+
if (existsSync(file)) {
|
|
587
|
+
const host = readJson(file);
|
|
588
|
+
if (alive(host.pid)) {
|
|
589
|
+
process.kill(host.pid, 'SIGTERM');
|
|
590
|
+
await until(() => !alive(host.pid));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
rmSync(socketDirectory(fixture.root), { recursive: true, force: true });
|
|
594
|
+
fixture.restore();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
test('old native hosts retain ordinary reads but refuse ensured creation before IPC', async () => {
|
|
598
|
+
const fixture = setup();
|
|
599
|
+
const socketRoot = socketDirectory(fixture.root);
|
|
600
|
+
privateDirectory(fixture.root);
|
|
601
|
+
privateDirectory(socketRoot);
|
|
602
|
+
const socket = join(socketRoot, 'legacy');
|
|
603
|
+
const requests = [];
|
|
604
|
+
const server = createServer({ allowHalfOpen: true }, (peer) => {
|
|
605
|
+
let input = '';
|
|
606
|
+
peer.on('data', (chunk) => {
|
|
607
|
+
input += chunk.toString();
|
|
608
|
+
});
|
|
609
|
+
peer.on('end', () => {
|
|
610
|
+
requests.push(JSON.parse(input).request.method);
|
|
611
|
+
peer.end(JSON.stringify({ value: [] }));
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
server.listen(socket);
|
|
615
|
+
await once(server, 'listening');
|
|
616
|
+
writeFileSync(join(fixture.root, 'host.json'), JSON.stringify({ pid: process.pid, socket, token: 'fixture-token' }), { mode: 0o600 });
|
|
617
|
+
writeFileSync(join(fixture.root, 'ready.json'), '{}', { mode: 0o600 });
|
|
618
|
+
try {
|
|
619
|
+
const client = new NativeSessionClient(fixture.root);
|
|
620
|
+
assert.deepEqual(await client.list('owner'), []);
|
|
621
|
+
await assert.rejects(client.ensure('owner', { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd }), /host upgrade required/);
|
|
622
|
+
assert.deepEqual(requests, ['list']);
|
|
623
|
+
await assert.rejects(client.ensureWorker('owner', { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd }, {
|
|
624
|
+
leaseId: randomUUID(),
|
|
625
|
+
safetyTier: 'sandboxed',
|
|
626
|
+
environment: { set: {}, unset: [] },
|
|
627
|
+
}), /host upgrade required for worker launch/);
|
|
628
|
+
assert.deepEqual(requests, ['list']);
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
632
|
+
rmSync(socketRoot, { recursive: true, force: true });
|
|
633
|
+
fixture.restore();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
test('recovery and terminal close require confirmed cleanup even when the wrapper is gone', async () => {
|
|
637
|
+
const fixture = setup();
|
|
638
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
639
|
+
try {
|
|
640
|
+
const session = await manager.create('owner', { runner: 'codex', cwd: fixture.cwd });
|
|
641
|
+
await manager.close('owner', session.id);
|
|
642
|
+
const stopped = manager.read('owner', session.id).session;
|
|
643
|
+
assert.equal(stopped.processStopped, true);
|
|
644
|
+
assert.equal(alive(stopped.processPid), false);
|
|
645
|
+
// Reproduce a durable cleanup failure after the wrapper has exited.
|
|
646
|
+
appendDurable(join(fixture.root, `${session.id}.journal`), {
|
|
647
|
+
info: { ...stopped, state: 'failed', processStopped: false },
|
|
648
|
+
});
|
|
649
|
+
const recovered = new NativeSessionManager(fixture.root);
|
|
650
|
+
await assert.rejects(recovered.close('owner', session.id), /cleanup is unconfirmed/);
|
|
651
|
+
await assert.rejects(recovered.create('owner', {
|
|
652
|
+
runner: 'codex',
|
|
653
|
+
cwd: fixture.cwd,
|
|
654
|
+
resumeSessionId: session.nativeSessionId,
|
|
655
|
+
}), /cleanup is unconfirmed/);
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
for (const session of manager.list('owner'))
|
|
659
|
+
await manager.close('owner', session.id);
|
|
660
|
+
fixture.restore();
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
test('a torn first journal write cannot reserve input ownership or prevent host startup', () => {
|
|
664
|
+
const fixture = setup();
|
|
665
|
+
try {
|
|
666
|
+
mkdirSync(fixture.root, { mode: 0o700 });
|
|
667
|
+
const journal = join(fixture.root, 'unstarted.journal');
|
|
668
|
+
writeFileSync(journal, '{"info":', { mode: 0o600 });
|
|
669
|
+
assert.deepEqual(new NativeSessionManager(fixture.root).list('owner'), []);
|
|
670
|
+
assert.equal(readFileSync(journal, 'utf8'), '');
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
fixture.restore();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
test('linear journal, durable uncertain receipt, exact recovery and stale generation decisions', async () => {
|
|
677
|
+
const fixture = setup();
|
|
678
|
+
const { cwd, root } = fixture;
|
|
679
|
+
let manager = new NativeSessionManager(root);
|
|
680
|
+
try {
|
|
681
|
+
const session = await manager.create('owner', { runner: 'codex', cwd });
|
|
682
|
+
await manager.send('owner', session.id, 'long', 'long-prompt-marker');
|
|
683
|
+
await until(() => manager.read('owner', session.id).session.state === 'idle');
|
|
684
|
+
const journal = readFileSync(join(root, `${session.id}.journal`), 'utf8');
|
|
685
|
+
const submittedEntry = journal
|
|
686
|
+
.trim()
|
|
687
|
+
.split('\n')
|
|
688
|
+
.map((line) => JSON.parse(line))
|
|
689
|
+
.find((entry) => entry.event?.type === 'command.submitted');
|
|
690
|
+
assert.equal(submittedEntry.commands[0].state, 'unknown');
|
|
691
|
+
assert.equal(submittedEntry.commands[0].submitted, true);
|
|
692
|
+
assert.ok(journal
|
|
693
|
+
.trim()
|
|
694
|
+
.split('\n')
|
|
695
|
+
.every((line) => Object.keys(JSON.parse(line)).length > 0));
|
|
696
|
+
assert.ok(journal.split('long-prompt-marker').length < 10, 'Stream events repeated prompt history');
|
|
697
|
+
await manager.send('owner', session.id, 'long', 'long-prompt-marker');
|
|
698
|
+
const submitted = manager
|
|
699
|
+
.read('owner', session.id, 0, 500)
|
|
700
|
+
.events.filter((event) => event.type === 'command.submitted');
|
|
701
|
+
assert.equal(submitted.length, 1);
|
|
702
|
+
assert.equal(submitted[0]?.text, 'long-prompt-marker');
|
|
703
|
+
assert.equal(submitted[0]?.commandId, 'long');
|
|
704
|
+
const page = manager.read('owner', session.id, 0, 17);
|
|
705
|
+
assert.equal(page.events.length, 17);
|
|
706
|
+
assert.equal(page.cursor, 17);
|
|
707
|
+
assert.equal(page.hasMore, true);
|
|
708
|
+
assert.throws(() => manager.read('owner', session.id, 0, 501), /limit/);
|
|
709
|
+
assert.equal(manager.read('owner', session.id, 17, 17).events[0]?.sequence, 18);
|
|
710
|
+
await manager.send('owner', session.id, 'pending-approval', 'approval');
|
|
711
|
+
await until(() => manager.read('owner', session.id).pendingRequests.length === 1);
|
|
712
|
+
const request = manager.read('owner', session.id).pendingRequests[0];
|
|
713
|
+
assert.notEqual(request.request.id, request.nativeId);
|
|
714
|
+
await assert.rejects(manager.respond('other', session.id, request.request.id, { decision: 'approve' }), /not found/);
|
|
715
|
+
await assert.rejects(manager.respond('owner', session.id, request.nativeId, { decision: 'approve' }), /stale/);
|
|
716
|
+
await manager.close('owner', session.id);
|
|
717
|
+
assert.equal(manager.read('owner', session.id).session.processStopped, true);
|
|
718
|
+
assert.equal(matchesProcess(process.pid, manager.read('owner', session.id).session.processIdentity), false);
|
|
719
|
+
manager = new NativeSessionManager(root);
|
|
720
|
+
const resumed = await manager.create('owner', {
|
|
721
|
+
runner: 'codex',
|
|
722
|
+
cwd,
|
|
723
|
+
resumeSessionId: session.nativeSessionId,
|
|
724
|
+
});
|
|
725
|
+
assert.equal(resumed.id, session.id);
|
|
726
|
+
assert.notEqual(resumed.generation, session.generation);
|
|
727
|
+
assert.equal((await manager.send('owner', session.id, 'long', 'long-prompt-marker')).state, 'completed');
|
|
728
|
+
await manager.send('owner', session.id, 'new-approval', 'approval');
|
|
729
|
+
await until(() => manager.read('owner', session.id).pendingRequests.length === 1);
|
|
730
|
+
await assert.rejects(manager.respond('owner', session.id, request.request.id, { decision: 'approve' }), /stale/);
|
|
731
|
+
const current = manager.read('owner', session.id).pendingRequests[0];
|
|
732
|
+
await manager.respond('owner', session.id, current.request.id, { decision: 'deny' });
|
|
733
|
+
await until(() => manager.read('owner', session.id).session.state === 'idle');
|
|
734
|
+
await assert.rejects(manager.send('owner', session.id, 'uncertain', 'unknown'), /exited|closed/);
|
|
735
|
+
await until(() => manager.read('owner', session.id).session.state === 'failed');
|
|
736
|
+
manager = new NativeSessionManager(root);
|
|
737
|
+
assert.equal((await manager.send('owner', session.id, 'uncertain', 'unknown')).state, 'unknown');
|
|
738
|
+
await manager.create('owner', {
|
|
739
|
+
runner: 'codex',
|
|
740
|
+
cwd,
|
|
741
|
+
resumeSessionId: session.nativeSessionId,
|
|
742
|
+
});
|
|
743
|
+
assert.equal((await manager.send('owner', session.id, 'uncertain', 'unknown')).state, 'unknown');
|
|
744
|
+
assert.equal(readFileSync(join(cwd, 'inputs'), 'utf8')
|
|
745
|
+
.split('\n')
|
|
746
|
+
.filter((line) => line === 'unknown').length, 1);
|
|
747
|
+
}
|
|
748
|
+
finally {
|
|
749
|
+
for (const session of manager.list('owner'))
|
|
750
|
+
await manager.close('owner', session.id);
|
|
751
|
+
fixture.restore();
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
test('local host survives caller exit, authenticates IPC, replays once and cleans host-crash descendants', async () => {
|
|
755
|
+
const fixture = setup();
|
|
756
|
+
const { cwd, root } = fixture;
|
|
757
|
+
const client = new NativeSessionClient(root);
|
|
758
|
+
try {
|
|
759
|
+
// Two independent clients race the first daemon launch.
|
|
760
|
+
assert.deepEqual(await Promise.all([client.list('owner'), new NativeSessionClient(root).list('owner')]), [[], []]);
|
|
761
|
+
const host = readJson(join(root, 'host.json'));
|
|
762
|
+
assert.ok(Buffer.byteLength(host.socket) < 104);
|
|
763
|
+
assert.equal(statSync(host.socket).mode & 0o777, 0o600);
|
|
764
|
+
assert.equal(statSync(root).mode & 0o777, 0o700);
|
|
765
|
+
await assert.rejects(requestHost({ ...host, token: '0'.repeat(64) }, { method: 'list', owner: 'owner' }), /authentication/);
|
|
766
|
+
const callerFile = join(cwd, 'caller.mjs');
|
|
767
|
+
const clientUrl = new URL(`./client.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`, import.meta.url).href;
|
|
768
|
+
writeFileSync(callerFile, `import {NativeSessionClient} from ${JSON.stringify(clientUrl)}; const c=new NativeSessionClient(${JSON.stringify(root)});const s=await c.create('owner',{runner:'codex',cwd:${JSON.stringify(cwd)}});await c.send('owner',s.id,'once','side-effect');console.log(JSON.stringify(s));`);
|
|
769
|
+
const caller = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), callerFile], {
|
|
770
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
771
|
+
});
|
|
772
|
+
let output = '';
|
|
773
|
+
let stderr = '';
|
|
774
|
+
caller.stdout.on('data', (chunk) => {
|
|
775
|
+
output += chunk.toString();
|
|
776
|
+
});
|
|
777
|
+
caller.stderr.on('data', (chunk) => {
|
|
778
|
+
stderr += chunk.toString();
|
|
779
|
+
});
|
|
780
|
+
const [code] = await once(caller, 'exit');
|
|
781
|
+
assert.equal(code, 0, stderr);
|
|
782
|
+
const session = JSON.parse(output);
|
|
783
|
+
assert.ok(alive(session.processPid));
|
|
784
|
+
assert.equal(existsSync(join(cwd, 'effects')), false, 'Task completed before caller exit');
|
|
785
|
+
await until(() => existsSync(join(cwd, 'effects')));
|
|
786
|
+
await until(async () => (await client.read('owner', session.id)).session.state === 'idle');
|
|
787
|
+
const reconnected = new NativeSessionClient(root);
|
|
788
|
+
const replay = [];
|
|
789
|
+
let cursor = 0;
|
|
790
|
+
let more = true;
|
|
791
|
+
while (more) {
|
|
792
|
+
const page = await reconnected.read('owner', session.id, cursor, 23);
|
|
793
|
+
replay.push(...page.events);
|
|
794
|
+
cursor = page.cursor;
|
|
795
|
+
more = page.hasMore;
|
|
796
|
+
}
|
|
797
|
+
assert.equal(new Set(replay.map((event) => event.sequence)).size, replay.length);
|
|
798
|
+
assert.equal((await reconnected.read('owner', session.id, cursor)).events.length, 0);
|
|
799
|
+
assert.equal((await reconnected.read('owner', session.id)).session.hostPid, session.hostPid);
|
|
800
|
+
await reconnected.send('owner', session.id, 'once', 'side-effect');
|
|
801
|
+
assert.equal(readFileSync(join(cwd, 'effects'), 'utf8'), 'side-effect\n');
|
|
802
|
+
await reconnected.send('owner', session.id, 'approval', 'approval');
|
|
803
|
+
await until(async () => (await client.read('owner', session.id)).pendingRequests.length === 1);
|
|
804
|
+
const secondCwd = join(cwd, 'second');
|
|
805
|
+
mkdirSync(secondCwd);
|
|
806
|
+
const second = await client.create('owner', { runner: 'codex', cwd: secondCwd });
|
|
807
|
+
assert.notEqual(second.nativeSessionId, session.nativeSessionId);
|
|
808
|
+
const pending = (await new NativeSessionClient(root).read('owner', session.id))
|
|
809
|
+
.pendingRequests[0];
|
|
810
|
+
await assert.rejects(client.respond('other', session.id, pending.request.id, { decision: 'approve' }), /not found/);
|
|
811
|
+
await assert.rejects(client.respond('owner', second.id, pending.request.id, { decision: 'approve' }), /stale/);
|
|
812
|
+
await client.close('owner', second.id);
|
|
813
|
+
assert.ok(alive(session.processPid));
|
|
814
|
+
await reconnected.respond('owner', session.id, pending.request.id, { decision: 'deny' });
|
|
815
|
+
await until(async () => (await client.read('owner', session.id)).session.state === 'idle');
|
|
816
|
+
rmSync(join(cwd, 'descendant'));
|
|
817
|
+
await reconnected.send('owner', session.id, 'crash', 'crash');
|
|
818
|
+
await until(() => existsSync(join(cwd, 'descendant')));
|
|
819
|
+
const descendant = Number(readFileSync(join(cwd, 'descendant'), 'utf8'));
|
|
820
|
+
// Freeze the only journal writer to reproduce a crash midway through its last append.
|
|
821
|
+
process.kill(session.hostPid, 'SIGSTOP');
|
|
822
|
+
appendFileSync(join(root, 'sessions', `${session.id}.journal`), '{"event":');
|
|
823
|
+
process.kill(session.hostPid, 'SIGKILL');
|
|
824
|
+
await until(() => !alive(session.processPid) && !alive(descendant) && !alive(host.pid));
|
|
825
|
+
const failed = await new NativeSessionClient(root).read('owner', session.id);
|
|
826
|
+
assert.equal(failed.session.state, 'failed');
|
|
827
|
+
assert.ok(failed.session.recovery);
|
|
828
|
+
assert.ok(!failed.events.some((event) => event.commandId === 'crash' && event.type === 'turn.completed'));
|
|
829
|
+
const resumed = await client.create('owner', {
|
|
830
|
+
runner: 'codex',
|
|
831
|
+
cwd,
|
|
832
|
+
resumeSessionId: session.nativeSessionId,
|
|
833
|
+
});
|
|
834
|
+
assert.equal(resumed.nativeSessionId, session.nativeSessionId);
|
|
835
|
+
assert.equal((await client.send('owner', session.id, 'crash', 'crash')).state, 'accepted');
|
|
836
|
+
assert.equal(readFileSync(join(cwd, 'inputs'), 'utf8')
|
|
837
|
+
.split('\n')
|
|
838
|
+
.filter((line) => line === 'crash').length, 1);
|
|
839
|
+
await client.close('owner', session.id);
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
const log = join(root, 'host.log');
|
|
843
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${existsSync(log) ? readFileSync(log, 'utf8') : ''}`, { cause: error });
|
|
844
|
+
}
|
|
845
|
+
finally {
|
|
846
|
+
const path = join(root, 'host.json');
|
|
847
|
+
if (existsSync(path)) {
|
|
848
|
+
const host = readJson(path);
|
|
849
|
+
if (alive(host.pid)) {
|
|
850
|
+
process.kill(host.pid, 'SIGTERM');
|
|
851
|
+
await until(() => !alive(host.pid));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Fixture owns this socket directory, never a production host.
|
|
855
|
+
rmSync(socketDirectory(root), { recursive: true, force: true });
|
|
856
|
+
fixture.restore();
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
test('native auth environment remains inherited but is absent from durable state', async () => {
|
|
860
|
+
const fixture = setup();
|
|
861
|
+
const originalKey = process.env.CODEX_LB_API_KEY;
|
|
862
|
+
const originalMarker = process.env.CLAUDECODE;
|
|
863
|
+
process.env.CODEX_LB_API_KEY = 'fixture-native-route';
|
|
864
|
+
process.env.CLAUDECODE = 'parent-context';
|
|
865
|
+
const executable = join(fixture.cwd, 'codex');
|
|
866
|
+
writeFileSync(executable, readFileSync(executable, 'utf8').replace("const fs = require('node:fs');", "if(process.env.CODEX_LB_API_KEY!=='fixture-native-route'||process.env.CLAUDECODE)process.exit(24);\nconst fs = require('node:fs');"));
|
|
867
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
868
|
+
try {
|
|
869
|
+
const session = await manager.create('owner', { runner: 'codex', cwd: fixture.cwd });
|
|
870
|
+
assert.ok(!readFileSync(join(fixture.root, `${session.id}.journal`), 'utf8').includes('fixture-native-route'));
|
|
871
|
+
await manager.close('owner', session.id);
|
|
872
|
+
const results = await Promise.allSettled([1, 2].map(() => manager.create('owner', {
|
|
873
|
+
runner: 'codex',
|
|
874
|
+
cwd: fixture.cwd,
|
|
875
|
+
resumeSessionId: session.nativeSessionId,
|
|
876
|
+
})));
|
|
877
|
+
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
|
878
|
+
assert.equal(results.filter((result) => result.status === 'rejected').length, 1);
|
|
879
|
+
}
|
|
880
|
+
finally {
|
|
881
|
+
for (const session of manager.list('owner'))
|
|
882
|
+
await manager.close('owner', session.id);
|
|
883
|
+
if (originalKey === undefined)
|
|
884
|
+
delete process.env.CODEX_LB_API_KEY;
|
|
885
|
+
else
|
|
886
|
+
process.env.CODEX_LB_API_KEY = originalKey;
|
|
887
|
+
if (originalMarker === undefined)
|
|
888
|
+
delete process.env.CLAUDECODE;
|
|
889
|
+
else
|
|
890
|
+
process.env.CLAUDECODE = originalMarker;
|
|
891
|
+
fixture.restore();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
test('native host storage follows FARMSLOT_HOME and its explicit override', () => {
|
|
895
|
+
const home = process.env.FARMSLOT_HOME;
|
|
896
|
+
const override = process.env.FARMSLOT_NATIVE_STATE_DIR;
|
|
897
|
+
try {
|
|
898
|
+
process.env.FARMSLOT_HOME = '/tmp/native-instance-home';
|
|
899
|
+
delete process.env.FARMSLOT_NATIVE_STATE_DIR;
|
|
900
|
+
assert.equal(new NativeSessionClient().root, '/tmp/native-instance-home/native-sessions');
|
|
901
|
+
process.env.FARMSLOT_NATIVE_STATE_DIR = '/tmp/native-explicit-state';
|
|
902
|
+
assert.equal(new NativeSessionClient().root, '/tmp/native-explicit-state');
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
if (home === undefined)
|
|
906
|
+
delete process.env.FARMSLOT_HOME;
|
|
907
|
+
else
|
|
908
|
+
process.env.FARMSLOT_HOME = home;
|
|
909
|
+
if (override === undefined)
|
|
910
|
+
delete process.env.FARMSLOT_NATIVE_STATE_DIR;
|
|
911
|
+
else
|
|
912
|
+
process.env.FARMSLOT_NATIVE_STATE_DIR = override;
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
test('unregistered host cannot bind and exits if its supervisor disappears', async () => {
|
|
916
|
+
const { fork } = await import('node:child_process');
|
|
917
|
+
const { fileURLToPath } = await import('node:url');
|
|
918
|
+
const root = mkdtempSync(join(tmpdir(), 'native-unregistered-host-'));
|
|
919
|
+
const extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
|
|
920
|
+
const child = fork(fileURLToPath(new URL(`./host.${extension}`, import.meta.url)), [root], {
|
|
921
|
+
execArgv: extension === 'ts' ? ['--import', import.meta.resolve('tsx')] : [],
|
|
922
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
923
|
+
});
|
|
924
|
+
let stderr = '';
|
|
925
|
+
child.stderr.on('data', (chunk) => {
|
|
926
|
+
stderr += chunk.toString();
|
|
927
|
+
});
|
|
928
|
+
const exited = once(child, 'exit');
|
|
929
|
+
try {
|
|
930
|
+
await delay(500);
|
|
931
|
+
assert.equal(existsSync(join(root, 'sessions')), false);
|
|
932
|
+
assert.equal(existsSync(join(socketDirectory(root), 's')), false);
|
|
933
|
+
child.disconnect();
|
|
934
|
+
const [code] = await exited;
|
|
935
|
+
assert.notEqual(code, 0);
|
|
936
|
+
assert.match(stderr, /before host registration/);
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
if (child.exitCode === null)
|
|
940
|
+
child.kill('SIGKILL');
|
|
941
|
+
rmSync(root, { recursive: true, force: true });
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
test('a stopped worker transfers its lease before resuming the saved conversation', async () => {
|
|
945
|
+
const fixture = setup();
|
|
946
|
+
const manager = new NativeSessionManager(fixture.root);
|
|
947
|
+
const params = { sessionId: randomUUID(), runner: 'codex', cwd: fixture.cwd };
|
|
948
|
+
const launch = {
|
|
949
|
+
leaseId: randomUUID(),
|
|
950
|
+
executable: join(fixture.cwd, 'codex'),
|
|
951
|
+
safetyTier: 'full-auto',
|
|
952
|
+
environment: { set: {}, unset: [] },
|
|
953
|
+
};
|
|
954
|
+
const successor = { ...launch, leaseId: randomUUID() };
|
|
955
|
+
try {
|
|
956
|
+
const first = await manager.ensure('owner', params, launch);
|
|
957
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId, first.generation);
|
|
958
|
+
const transferred = manager.transferWorker('owner', first.id, first.generation, launch.leaseId, successor);
|
|
959
|
+
assert.equal(transferred.processStopped, true);
|
|
960
|
+
assert.equal(transferred.generation, first.generation);
|
|
961
|
+
assert.equal(transferred.workerLeaseId, successor.leaseId);
|
|
962
|
+
assert.throws(() => manager.assertWorkerLease('owner', first.id, first.generation, launch.leaseId), /lease changed/);
|
|
963
|
+
const resumed = await manager.create('owner', { ...params, resumeSessionId: first.nativeSessionId }, successor);
|
|
964
|
+
assert.equal(resumed.nativeSessionId, first.nativeSessionId);
|
|
965
|
+
assert.notEqual(resumed.generation, first.generation);
|
|
966
|
+
await manager.cancelWorker('owner', first.id, launch.leaseId, first.generation);
|
|
967
|
+
assert.ok(alive(resumed.processPid));
|
|
968
|
+
assert.equal(manager.read('owner', first.id).session.workerLeaseId, successor.leaseId);
|
|
969
|
+
}
|
|
970
|
+
finally {
|
|
971
|
+
for (const session of manager.list('owner'))
|
|
972
|
+
await manager.close('owner', session.id);
|
|
973
|
+
fixture.restore();
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
//# sourceMappingURL=durability.test.js.map
|