@jc_stack/ez-agents 0.1.0-beta.50 → 0.1.0-beta.51
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 +11 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +7 -2
- package/package.json +4 -2
- package/src/ai-cli.ts +4 -2
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +54 -10
- package/src/host-executor-client.ts +4 -1
- package/src/host-executor.ts +63 -32
- package/src/index.ts +37 -19
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +8 -2
- package/src/schedule-cli.ts +6 -3
- package/src/scheduled-tasks.ts +12 -10
- package/src/scheduler.ts +6 -7
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +24 -2
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +8 -1
- package/test/plugin-manager.test.mjs +1 -1
- package/test/run-handoff.test.ts +4 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +23 -15
- package/test/updates.test.mjs +36 -2
package/test/ai-cli.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|
|
6
6
|
import {spawnSync} from 'node:child_process'
|
|
7
7
|
import {fileURLToPath} from 'node:url'
|
|
8
8
|
import {ControlStore} from '../src/control-state.js'
|
|
9
|
+
import {executorJobEnv} from '../src/executor.js'
|
|
9
10
|
import {initialPreset} from '../src/ai.js'
|
|
10
11
|
|
|
11
12
|
test('explicit CLI/model selection preserves installation default and rejects unavailable choices',async()=>{
|
|
@@ -50,12 +51,33 @@ test('an agent can select a declared provider and model without putting the key
|
|
|
50
51
|
await writeFile(path.join(binDir,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
51
52
|
await writeFile(path.join(controlDir,'host-executor','models.json'),JSON.stringify([{cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',name:'Gemini',efforts:[]}]))
|
|
52
53
|
const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('codex'))
|
|
53
|
-
const env={...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,
|
|
54
|
+
const env=executorJobEnv({runId:'fixture',controlDir,binDir,catalogTransport:'host'},{...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,OPENROUTER_API_KEY:'must-not-persist'})
|
|
54
55
|
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
55
56
|
const result=spawnSync(process.execPath,[bin,'select','--cli','codex','--provider','openrouter','--model','google/gemini-3.8-flash'],{env,encoding:'utf8'})
|
|
56
57
|
assert.equal(result.status,0,result.stderr)
|
|
57
58
|
const state=await store.status(),selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
|
|
58
59
|
assert.equal(selected?.provider,'openrouter');assert.equal(selected?.model,'google/gemini-3.8-flash')
|
|
59
60
|
assert.equal(JSON.stringify(state).includes('must-not-persist'),false)
|
|
61
|
+
assert.equal(env.EZ_EXECUTOR_TRANSPORT,undefined)
|
|
62
|
+
assert.equal(env.EZ_ISOLATION,undefined)
|
|
63
|
+
}finally{await rm(root,{recursive:true,force:true})}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
test('isolated AI CLI ignores a legacy host catalog and discovers its own Sonnet',async()=>{
|
|
68
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-ai-local-catalog-'))
|
|
69
|
+
try{
|
|
70
|
+
const controlDir=path.join(root,'control'),binDir=path.join(root,'bin')
|
|
71
|
+
await mkdir(path.join(controlDir,'host-executor'),{recursive:true});await mkdir(binDir)
|
|
72
|
+
const stale=JSON.stringify([{cli:'codex',model:'host-only',name:'Host only',efforts:[]}])
|
|
73
|
+
await writeFile(path.join(controlDir,'host-executor','models.json'),stale)
|
|
74
|
+
await writeFile(path.join(binDir,'claude'),"#!/bin/sh\nprintf '%s\n' \" --model <model> Model (e.g. 'sonnet', 'opus')\" \" --effort <level> Effort (low, high)\"\n",{mode:0o700})
|
|
75
|
+
const env={...process.env,HOME:root,PATH:binDir,EZ_CONTROL_DIR:controlDir,EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}
|
|
76
|
+
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
77
|
+
const result=spawnSync(process.execPath,[bin,'list'],{env,encoding:'utf8'})
|
|
78
|
+
assert.equal(result.status,0,result.stderr)
|
|
79
|
+
const catalog=JSON.parse(result.stdout)
|
|
80
|
+
assert.ok(catalog.some((m:{cli:string;model:string})=>m.cli==='claude'&&m.model==='sonnet'))
|
|
81
|
+
assert.ok(!catalog.some((m:{model:string})=>m.model==='host-only'))
|
|
60
82
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
61
83
|
})
|
package/test/ai.test.ts
CHANGED
|
@@ -631,7 +631,7 @@ test('private channel model choices do not consume owner preset slots', async ()
|
|
|
631
631
|
assert.equal(readback.preset?.model, model.model)
|
|
632
632
|
assert.equal(readback.preset?.effort, 'low')
|
|
633
633
|
assert.deepEqual((await store.status()).ai, before)
|
|
634
|
-
|
|
634
|
+
assert.equal((await menu.saveSelection(model, 'max')).effort, 'max')
|
|
635
635
|
await assert.rejects(store.changeApplicationSession(scope, {...guard, expectedSession:null}, preset), /Conversation changed/)
|
|
636
636
|
assert.deepEqual((await store.applicationSession(scope))?.preset, readback.preset)
|
|
637
637
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
@@ -648,7 +648,29 @@ test('detected presets do not count against the saved AI cap', async () => {
|
|
|
648
648
|
assert.equal((await store.status()).ai!.presets.length, 13)
|
|
649
649
|
const next = {id:'one-more', name:'One more', cli:'claude', model:'sonnet', effort:'medium'}
|
|
650
650
|
await store.savePreset(next)
|
|
651
|
-
await
|
|
651
|
+
await store.savePreset({...next, id:'one-past-cap', name:'One past cap'})
|
|
652
|
+
const ids = (await store.status()).ai!.presets.map(p => p.id)
|
|
653
|
+
assert.equal(ids.filter(id => !id.startsWith('detected_')).length, 12)
|
|
654
|
+
assert.ok(!ids.includes('saved-1') && ids.includes('one-past-cap') && ids.includes(initial.id))
|
|
655
|
+
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('a new model choice at the saved AI cap replaces the oldest unused choice', async () => {
|
|
659
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-cap-select-'))
|
|
660
|
+
try {
|
|
661
|
+
const store = new ControlStore(dir, 1000)
|
|
662
|
+
const initial = initialPreset('claude')
|
|
663
|
+
await store.captureChoice(initial)
|
|
664
|
+
for (let n = 1; n < 12; n++) await store.savePreset({...initial, id:`saved-${n}`, name:`Saved ${n}`, model:'opus', effort:n % 2 ? 'high' : 'xhigh'})
|
|
665
|
+
await store.selectPreset('saved-1', (await store.getActiveSession())?.sessionId ?? null)
|
|
666
|
+
const model = {cli:'claude', model:'sonnet', name:'sonnet', efforts:['low', 'high']}
|
|
667
|
+
const menu = createAiMenu(store, 'claude', async () => [model], dir, undefined, async () => true)
|
|
668
|
+
const preset = await menu.saveSelection(model, 'high')
|
|
669
|
+
await menu.select(preset, (await store.getActiveSession())?.sessionId ?? null)
|
|
670
|
+
const ai = (await store.status()).ai!
|
|
671
|
+
assert.equal(ai.presets.find(p => p.id === ai.selectedId)?.model, 'sonnet')
|
|
672
|
+
assert.equal(ai.presets.length, 12)
|
|
673
|
+
assert.ok(ai.presets.some(p => p.id === 'saved-1') && !ai.presets.some(p => p.id === 'saved-2'))
|
|
652
674
|
} finally { await rm(dir, {recursive:true, force:true}) }
|
|
653
675
|
})
|
|
654
676
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, rm, unlink } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { createConnection } from 'node:net'
|
|
@@ -64,9 +64,48 @@ test('a response timeout is not replayed over the loopback endpoint', async t =>
|
|
|
64
64
|
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
|
|
65
65
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
66
66
|
let calls = 0
|
|
67
|
-
const server = await serveDeliverySocket(dir, async () => { calls++;
|
|
67
|
+
const server = await serveDeliverySocket(dir, async () => { calls++; await new Promise(resolve=>setTimeout(resolve,300)); return {accepted:true} }, { tcpPort: 0 })
|
|
68
68
|
t.after(() => server.stop())
|
|
69
69
|
assert.ok(server.endpoint)
|
|
70
70
|
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
|
|
71
71
|
assert.equal(calls, 1)
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
test('shutdown waits for an admitted enqueue before the final delivery drain',async t=>{
|
|
75
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-delivery-admission-'))
|
|
76
|
+
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
77
|
+
let release!:()=>void,entered!:()=>void,accepted=0,drained=0
|
|
78
|
+
const gate=new Promise<void>(resolve=>{release=resolve}),admitted=new Promise<void>(resolve=>{entered=resolve})
|
|
79
|
+
const server=await serveDeliverySocket(dir,async()=>{entered();await gate;accepted++;return {accepted:true}},{beforeClose:async()=>{drained=accepted}})
|
|
80
|
+
const request=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'})
|
|
81
|
+
await admitted
|
|
82
|
+
let stopped=false
|
|
83
|
+
const stop=server.stop().then(()=>{stopped=true})
|
|
84
|
+
await new Promise(resolve=>setTimeout(resolve,50))
|
|
85
|
+
assert.equal(stopped,false)
|
|
86
|
+
assert.equal(drained,0)
|
|
87
|
+
release()
|
|
88
|
+
assert.deepEqual(await request,{accepted:true})
|
|
89
|
+
await stop
|
|
90
|
+
assert.equal(drained,1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
test('a swap connection failure waits for the relay but an accepted timeout is never retried',async t=>{
|
|
95
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-delivery-swap-'))
|
|
96
|
+
t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
97
|
+
await writeFile(join(dir,'upgrade-pause.json'),'{}')
|
|
98
|
+
const waiting=callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},1000)
|
|
99
|
+
await new Promise(r=>setTimeout(r,300))
|
|
100
|
+
let accepted=0
|
|
101
|
+
const server=await serveDeliverySocket(dir,async()=>({accepted:++accepted}))
|
|
102
|
+
t.after(()=>server.stop())
|
|
103
|
+
assert.deepEqual(await waiting,{accepted:1})
|
|
104
|
+
assert.equal(accepted,1)
|
|
105
|
+
await server.stop()
|
|
106
|
+
let attempted=0
|
|
107
|
+
const hanging=await serveDeliverySocket(dir,async()=>{attempted++;await new Promise(r=>setTimeout(r,200));return {accepted:true}})
|
|
108
|
+
t.after(()=>hanging.stop())
|
|
109
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir),{op:'enqueue'},40),/unavailable/)
|
|
110
|
+
assert.equal(attempted,1)
|
|
111
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { ownerRun } from './helpers/owner-run.js'
|
|
|
2
2
|
import { RunStore } from '../src/runs.js'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import test from 'node:test'
|
|
5
|
-
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
5
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile, rename, stat } from 'node:fs/promises'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { EXECUTOR_REGISTRY, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
9
|
+
import { EXECUTOR_REGISTRY, attachHostJob, executorEnvironment, grokInvocation, grokJobEnv, opencodeDataHome, opencodeInvocation, piAgentDir, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
10
|
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
11
11
|
import { splitTelegramText } from '../src/reply.js'
|
|
12
12
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
@@ -139,8 +139,10 @@ test('host transport does not throw when selecting the desktop adapter', async (
|
|
|
139
139
|
workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
|
|
140
140
|
})
|
|
141
141
|
assert.ok(job.child.pid)
|
|
142
|
-
|
|
143
|
-
await
|
|
142
|
+
const closed = new Promise((resolve) => job.child.once('close', resolve))
|
|
143
|
+
await terminateJob(job.child)
|
|
144
|
+
await closed
|
|
145
|
+
assert.equal(await readFile(path.join(controlDir,'host-executor/r_hostgui.cancel'),'utf8'),'')
|
|
144
146
|
await job.cleanup()
|
|
145
147
|
} finally {
|
|
146
148
|
if (prior === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
@@ -149,6 +151,32 @@ test('host transport does not throw when selecting the desktop adapter', async (
|
|
|
149
151
|
}
|
|
150
152
|
})
|
|
151
153
|
|
|
154
|
+
test('host cancellation persists before a stalled observer receives a signal', {skip:process.platform==='win32'}, async () => {
|
|
155
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-cancel-observer-'))
|
|
156
|
+
const directory=path.join(root,'host-executor'),id='r_stalled'
|
|
157
|
+
await mkdir(directory)
|
|
158
|
+
await assert.rejects(attachHostJob(root,'../escape'),/Invalid host executor run ID/)
|
|
159
|
+
await writeFile(path.join(directory,id+'.running.json'),'{}')
|
|
160
|
+
const child=await attachHostJob(root,id)
|
|
161
|
+
const closed=new Promise(resolve=>child.once('close',resolve))
|
|
162
|
+
child.kill('SIGSTOP')
|
|
163
|
+
try {
|
|
164
|
+
await rename(directory,directory+'.saved')
|
|
165
|
+
await writeFile(directory,'Blocked transport directory')
|
|
166
|
+
assert.equal(await terminateJob(child),false)
|
|
167
|
+
assert.equal(child.exitCode,null)
|
|
168
|
+
assert.equal(child.signalCode,null)
|
|
169
|
+
await assert.rejects(readFile(path.join(directory+'.saved',id+'.cancel')),{code:'ENOENT'})
|
|
170
|
+
await rm(directory);await rename(directory+'.saved',directory)
|
|
171
|
+
assert.equal(await terminateJob(child),true)
|
|
172
|
+
assert.equal(await readFile(path.join(directory,id+'.cancel'),'utf8'),'')
|
|
173
|
+
assert.equal((await stat(path.join(directory,id+'.cancel'))).mode&0o777,0o600)
|
|
174
|
+
} finally {
|
|
175
|
+
child.kill('SIGCONT');child.kill('SIGKILL')
|
|
176
|
+
await closed;await rm(root,{recursive:true,force:true})
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
152
180
|
test('Codex jobs disable global memory and host skill discovery', () => {
|
|
153
181
|
const args = EXECUTOR_REGISTRY.codex.buildArgs({workspace:'/agent'},'/prompt','hello')
|
|
154
182
|
assert.ok(args.includes('memories'))
|
|
@@ -8,7 +8,7 @@ import { serveHostExecutor, refreshAgentCatalog } from '../src/host-executor.js'
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { isHostRunId } from '../src/host-executor-protocol.js'
|
|
11
|
-
import { EXECUTOR_REGISTRY } from '../src/executor.js'
|
|
11
|
+
import { EXECUTOR_REGISTRY, attachHostJob, terminateJob } from '../src/executor.js'
|
|
12
12
|
import { RunStore } from '../src/runs.js'
|
|
13
13
|
import { packageVersion } from '../src/version.js'
|
|
14
14
|
import { executionDefaults } from '../src/model-policy.js'
|
|
@@ -294,11 +294,17 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
294
294
|
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
295
295
|
await ownerRun(agents[0].controlDir,'tg_43')
|
|
296
296
|
await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
|
|
297
|
-
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'))
|
|
297
|
+
for(let n=0;n<100;n++){try{if(JSON.parse(await readFile(path.join(directory,'tg_43.process.json'),'utf8')).pid)break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
298
298
|
await readFile(path.join(directory,'tg_43.running.json'))
|
|
299
299
|
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
300
300
|
await readFile(path.join(directory,'r_hold.running.json'))
|
|
301
|
-
|
|
301
|
+
// An observer that cannot handle signals must still stop the native run.
|
|
302
|
+
const observer=await attachHostJob(agents[0].controlDir,'r_hold')
|
|
303
|
+
const observerClosed=new Promise(resolve=>observer.once('close',resolve))
|
|
304
|
+
observer.stdout?.resume();observer.stderr?.resume()
|
|
305
|
+
observer.kill('SIGSTOP')
|
|
306
|
+
try { assert.equal(await terminateJob(observer),true) }
|
|
307
|
+
finally { observer.kill('SIGCONT');observer.kill('SIGKILL');await observerClosed }
|
|
302
308
|
let heldOutput=''
|
|
303
309
|
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
304
310
|
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
@@ -568,3 +574,64 @@ test('host transport runs a registered script from the run record and refuses ch
|
|
|
568
574
|
await rm(root,{recursive:true,force:true})
|
|
569
575
|
}
|
|
570
576
|
})
|
|
577
|
+
|
|
578
|
+
test('a native process that settles during launch records completion without hanging the worker',async t=>{
|
|
579
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-fast-native-')),workspace=path.join(root,'mind'),controlDir=path.join(root,'control')
|
|
580
|
+
await mkdir(workspace);await mkdir(controlDir)
|
|
581
|
+
const abort=new AbortController(),directory=path.join(controlDir,'host-executor')
|
|
582
|
+
const launch=async()=>{
|
|
583
|
+
const child=spawn(process.execPath,['-e','process.exit(0)'],{stdio:['pipe','pipe','pipe']})
|
|
584
|
+
await new Promise(resolve=>child.once('close',resolve))
|
|
585
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
586
|
+
}
|
|
587
|
+
const server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,launch,async()=>[])
|
|
588
|
+
t.after(async()=>{abort.abort();await server;await rm(root,{recursive:true,force:true})})
|
|
589
|
+
for(let n=0;n<300;n++){try{await readFile(path.join(directory,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
590
|
+
await ownerRun(controlDir,'r_fast_native')
|
|
591
|
+
await writeFile(path.join(directory,'r_fast_native.request.json'),JSON.stringify({texts:['fast'],options:{cli:'grok'}}))
|
|
592
|
+
let events=''
|
|
593
|
+
for(let n=0;n<300;n++){try{events=await readFile(path.join(directory,'r_fast_native.events'),'utf8');if(events.includes('"stream":"exit"'))break}catch{}await new Promise(r=>setTimeout(r,20))}
|
|
594
|
+
assert.match(events,/"stream":"exit","code":0/)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('retiring native generation preserves its run while the replacement claims new work once',async t=>{
|
|
598
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-generation-'))
|
|
599
|
+
const workspace=path.join(root,'mind'),controlDir=path.join(root,'control'),toolsHome=path.join(root,'tools')
|
|
600
|
+
await mkdir(workspace);await mkdir(toolsHome);await mkdir(controlDir)
|
|
601
|
+
await writeFile(path.join(toolsHome,'config.json'),JSON.stringify({schemaVersion:1,workspace:await realpath(workspace)}))
|
|
602
|
+
const agent={name:'qa',workspace,controlDir,toolsHome,binDir:root},installation={cli:'grok',agents:[agent]}
|
|
603
|
+
const stopA=new AbortController(),stopB=new AbortController(),retireA=new AbortController()
|
|
604
|
+
const children=new Map<string,ReturnType<typeof spawn>>(),launched:string[]=[]
|
|
605
|
+
const launch=async(_texts:string[],options:any)=>{
|
|
606
|
+
launched.push(options.runId)
|
|
607
|
+
const child=spawn(process.execPath,['-e',"process.stdin.resume();process.stdin.once('data',()=>process.exit(0));"],{stdio:['pipe','pipe','pipe']})
|
|
608
|
+
children.set(options.runId,child)
|
|
609
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
610
|
+
}
|
|
611
|
+
let a:Promise<void>|undefined,b:Promise<void>|undefined
|
|
612
|
+
t.after(async()=>{stopA.abort();stopB.abort();for(const child of children.values())child.stdin?.end('done');await Promise.all([a,b]);await rm(root,{recursive:true,force:true})})
|
|
613
|
+
const directory=path.join(controlDir,'host-executor')
|
|
614
|
+
const wait=async(check:()=>Promise<boolean>)=>{for(let n=0;n<300;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}assert.fail('generation did not reach expected state')}
|
|
615
|
+
const exists=(file:string)=>readFile(file).then(()=>true,()=>false)
|
|
616
|
+
a=serveHostExecutor(installation,stopA.signal,launch,async()=>[],retireA.signal)
|
|
617
|
+
await wait(()=>exists(path.join(directory,'heartbeat.json')))
|
|
618
|
+
await ownerRun(controlDir,'r_old_generation')
|
|
619
|
+
await writeFile(path.join(directory,'r_old_generation.request.json'),JSON.stringify({texts:['old'],options:{cli:'grok'}}))
|
|
620
|
+
await wait(async()=>children.has('r_old_generation'))
|
|
621
|
+
retireA.abort()
|
|
622
|
+
await wait(async()=>!await exists(path.join(directory,'worker.lock')))
|
|
623
|
+
assert.equal(children.get('r_old_generation')!.exitCode,null)
|
|
624
|
+
b=serveHostExecutor(installation,stopB.signal,launch,async()=>[])
|
|
625
|
+
await wait(()=>exists(path.join(directory,'worker.lock')))
|
|
626
|
+
await ownerRun(controlDir,'r_new_generation')
|
|
627
|
+
await writeFile(path.join(directory,'r_new_generation.request.json'),JSON.stringify({texts:['new'],options:{cli:'grok'}}))
|
|
628
|
+
await wait(async()=>children.has('r_new_generation'))
|
|
629
|
+
assert.deepEqual(launched,['r_old_generation','r_new_generation'])
|
|
630
|
+
children.get('r_old_generation')!.stdin!.end('done')
|
|
631
|
+
await a
|
|
632
|
+
assert.ok(await exists(path.join(directory,'worker.lock')),'retiring host must not delete replacement lock')
|
|
633
|
+
assert.match(await readFile(path.join(directory,'r_old_generation.events'),'utf8'),/"stream":"exit","code":0/)
|
|
634
|
+
children.get('r_new_generation')!.stdin!.end('done')
|
|
635
|
+
await wait(async()=>(await readFile(path.join(directory,'r_new_generation.events'),'utf8')).includes('"stream":"exit"'))
|
|
636
|
+
assert.match(await readFile(path.join(directory,'r_new_generation.events'),'utf8'),/"stream":"exit","code":0/)
|
|
637
|
+
})
|
|
@@ -295,7 +295,7 @@ test('conversation menu is owner-only and removes the retired settings control',
|
|
|
295
295
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
296
296
|
assert.ok(!f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
297
297
|
await f.relay.bot.handleUpdate(message(9, '/tasks'))
|
|
298
|
-
assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No
|
|
298
|
+
assert.match(f.replies.at(-1)!, /📅 Scheduled tasks[\s\S]*No scheduled tasks/)
|
|
299
299
|
await f.relay.bot.handleUpdate(message(10, '/tasks 2'))
|
|
300
300
|
assert.match(f.replies.at(-1)!, /Scheduled task 2 was not found/)
|
|
301
301
|
assert.equal(f.launched.length, 0)
|
|
@@ -810,6 +810,13 @@ test('tasks list and detail show saved frequency and ordered setup path without
|
|
|
810
810
|
assert.ok(detail.includes('Schedule\nEvery 30 minutes · America/New_York'))
|
|
811
811
|
assert.ok(detail.includes(`Execution path\n${path}`))
|
|
812
812
|
assert.deepEqual(await scheduler.get(saved.id),saved)
|
|
813
|
+
const paused=await scheduler.enable(saved.id,false)
|
|
814
|
+
await f.relay.bot.handleUpdate(message(3,'/tasks'))
|
|
815
|
+
assert.match(f.replies.at(-1)!,/0 active · 1 paused[\s\S]*⏸ Paused[\s\S]*Next · —/)
|
|
816
|
+
await f.relay.bot.handleUpdate(message(4,'/tasks 1'))
|
|
817
|
+
assert.match(f.replies.at(-1)!,/⏸ Paused[\s\S]*Next run\n—/)
|
|
818
|
+
assert.ok(f.replies.at(-1)!.includes(`Execution path\n${path}`))
|
|
819
|
+
assert.deepEqual(await scheduler.get(saved.id),paused)
|
|
813
820
|
assert.equal(f.launched.length,0)
|
|
814
821
|
}finally{await f.close()}
|
|
815
822
|
})
|
|
@@ -283,7 +283,7 @@ test('a lease from another PID namespace is live while its call container runs,
|
|
|
283
283
|
// Isolated broker lease: its pid means nothing in this namespace (EPERM/ESRCH or an unrelated process).
|
|
284
284
|
await fs.writeFile(lease,JSON.stringify({pid:1,container:'broker-call',pidNamespace:'pid:[4026531999]'}),{mode:0o600});
|
|
285
285
|
const path0=process.env.PATH;process.env.PATH=f.fake+path.delimiter+path0;t.after(()=>{process.env.PATH=path0;});
|
|
286
|
-
const docker=state=>
|
|
286
|
+
const docker=async state=>{const file=path.join(f.fake,'docker');await fs.writeFile(file+'.next',`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){${state}}\n`,{mode:0o700});await fs.rename(file+'.next',file);};
|
|
287
287
|
await docker(`console.log('true')`);
|
|
288
288
|
await assert.rejects(locked(f.home,async()=>{}),/commands are active/); // never misreported as stale
|
|
289
289
|
let activated=false;const drain=locked(f.home,async()=>{activated=true;},{drainInvocations:true});
|
package/test/run-handoff.test.ts
CHANGED
|
@@ -56,6 +56,9 @@ test('a graceful relay stop hands running host work to the next relay, which rec
|
|
|
56
56
|
await rename(join(host, id + '.request.json'), join(host, id + '.running.json'))
|
|
57
57
|
await appendFile(join(host, id + '.events'), JSON.stringify({ stream: 'stdout', text: 'working\n' }) + '\n')
|
|
58
58
|
}
|
|
59
|
+
const delivered=await first.enqueueMessage(scheduled.id,'Delivered before replacement')
|
|
60
|
+
await first.claimOutbox(delivered.id)
|
|
61
|
+
await first.markOutboxSent(delivered.id,[17])
|
|
59
62
|
await before.stop()
|
|
60
63
|
assert.equal((await stat(join(dir, 'run-handoff.json'))).mode & 0o777, 0o600)
|
|
61
64
|
const handoff = JSON.parse(await readFile(join(dir, 'run-handoff.json'), 'utf8'))
|
|
@@ -72,6 +75,7 @@ test('a graceful relay stop hands running host work to the next relay, which rec
|
|
|
72
75
|
await after.adoptHandoff()
|
|
73
76
|
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
74
77
|
assert.deepEqual((await runs.list()).map(run => run.status), ['running', 'running'])
|
|
78
|
+
assert.deepEqual(await runs.waitForDelivery(delivered.id,100),await first.waitForDelivery(delivered.id,100))
|
|
75
79
|
// The new ledger accepts the run's result delivery and holds its recurring schedule.
|
|
76
80
|
assert.ok(await runs.enqueueMessage(scheduled.id, 'Result after replacement'))
|
|
77
81
|
await scheduler.tick(owner, runs, due + 60_000); await after.drainSources()
|
|
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import { execFile } from 'node:child_process'
|
|
4
4
|
import { promisify } from 'node:util'
|
|
5
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
@@ -11,6 +11,7 @@ import { RunStore } from '../src/runs.js'
|
|
|
11
11
|
import { initialPreset } from '../src/ai.js'
|
|
12
12
|
import { serveTestLedger } from './helpers/ledger.js'
|
|
13
13
|
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { Scripts } from '../src/scripts.js'
|
|
14
15
|
import { callDeliverySocket, socketPathFor } from '../src/delivery-socket.js'
|
|
15
16
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
16
17
|
test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
|
|
@@ -66,6 +67,28 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
66
67
|
await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/owner-authorized/)
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
test('edit keeps a paused agent or script schedule paused and an enabled one enabled',async t=>{
|
|
71
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-edit-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
72
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
73
|
+
const control=new ControlStore(dir,1000),workspace=join(dir,'workspace')
|
|
74
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
75
|
+
await mkdir(workspace);await writeFile(join(workspace,'guard.mjs'),'')
|
|
76
|
+
await new Scripts(dir).save({id:'guard',owner:(await control.status()).owner!,workspace,entry:'guard.mjs',interpreter:'node',args:[],timeoutSeconds:60},true)
|
|
77
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
78
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
79
|
+
const tasks={agent:(text:string)=>['--model','gpt-6-astra','--text',text],script:(name:string)=>['--script','guard','--name',name]}
|
|
80
|
+
for(const [id,task] of Object.entries(tasks)){
|
|
81
|
+
await cli('create',id,...task('Original'),'--every-seconds','3600')
|
|
82
|
+
await cli('pause',id)
|
|
83
|
+
// Changing only the instructions, even with --now, must not resume or dispatch it.
|
|
84
|
+
const paused=await cli('edit',id,...task('Changed'),'--now')
|
|
85
|
+
assert.equal(paused.enabled,false);assert.equal(paused.nextEligibleAt,null)
|
|
86
|
+
assert.equal((await cli('show',id)).enabled,false)
|
|
87
|
+
await cli('resume',id)
|
|
88
|
+
assert.equal((await cli('edit',id,...task('Again'),'--every-seconds','3600')).enabled,true)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
69
92
|
test('executor PATH exposes the extensionless scheduler command',async()=>{
|
|
70
93
|
const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
|
|
71
94
|
assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
|
|
@@ -38,10 +38,10 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
38
38
|
scheduled: { id: saved.id, revision: saved.revision, dueAt: '2026-01-01T05:00:00.000Z', pairedAt: owner.pairedAt },
|
|
39
39
|
})
|
|
40
40
|
await runs.patch(run.id, { status: 'completed', endedAt: '2026-01-01T05:04:00.000Z' })
|
|
41
|
-
const active = await scheduler.
|
|
41
|
+
const active = await scheduler.listTasksReadOnly(await runs.list())
|
|
42
42
|
const text = scheduledTasksText(active, owner)
|
|
43
43
|
|
|
44
|
-
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nNext/last times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Schedule · Weekdays at 09:00 · Asia/Dubai\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n Execution · codex (default login) · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active · 0 paused\n\nNext/last times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Schedule · Weekdays at 09:00 · Asia/Dubai\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n Execution · codex (default login) · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
45
45
|
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
46
46
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
47
47
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
@@ -62,29 +62,37 @@ test('active view follows existing cursor and run state without changing schedul
|
|
|
62
62
|
id==='interrupted'?{everySeconds:60,start:new Date(due).toISOString()}:{at:new Date(due).toISOString()}})
|
|
63
63
|
}
|
|
64
64
|
await scheduler.save({...common,id:'recurring',name:'recurring',trigger:{everySeconds:60,start:new Date(due).toISOString()}})
|
|
65
|
-
const names = async () => (await scheduler.
|
|
66
|
-
assert.deepEqual(await names(),['expired','interrupted','once','recurring','review'])
|
|
65
|
+
const names = async () => (await scheduler.listTasksReadOnly(await runs.list())).map(s=>s.id).sort()
|
|
66
|
+
assert.deepEqual(await names(),['expired','interrupted','once','paused','recurring','review'])
|
|
67
|
+
const paused = (await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='paused')!
|
|
68
|
+
assert.equal(paused.enabled,false)
|
|
69
|
+
assert.equal(paused.nextAt,null)
|
|
70
|
+
assert.match(scheduledTasksText([paused],owner),/0 active · 1 paused[\s\S]*⏸ Paused/)
|
|
71
|
+
assert.doesNotMatch(scheduledTasksText([paused],{...owner,pairedAt:'different'}),/⏸ Paused/)
|
|
72
|
+
assert.match(scheduledTaskDetailText({...paused,runState:'running'},1),/▶ Running · ⏸ Paused/)
|
|
73
|
+
assert.match(scheduledTaskDetailText({...paused,runState:'queued'},1),/◌ Queued · ⏸ Paused/)
|
|
67
74
|
await scheduler.tick(owner,runs,due)
|
|
75
|
+
assert.ok(!(await runs.list()).some(r=>r.scheduled?.id==='paused'))
|
|
68
76
|
// The empty conditional review consumes its occurrence without creating work.
|
|
69
|
-
assert.deepEqual(await names(),['expired','interrupted','once','recurring'])
|
|
77
|
+
assert.deepEqual(await names(),['expired','interrupted','once','paused','recurring'])
|
|
70
78
|
const queued = (await runs.list()).find(r=>r.scheduled?.id==='once')!
|
|
71
|
-
const view = (await scheduler.
|
|
79
|
+
const view = (await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='once')!
|
|
72
80
|
assert.equal(view.nextAt,null)
|
|
73
81
|
assert.equal(view.runState,'queued')
|
|
74
82
|
assert.match(scheduledTasksText([view],owner),/Queued/)
|
|
75
83
|
await runs.patch(queued.id,{status:'running'})
|
|
76
|
-
assert.equal((await scheduler.
|
|
84
|
+
assert.equal((await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='once')!.runState,'running')
|
|
77
85
|
for (const run of await runs.list()) await runs.patch(run.id,run.scheduled?.id==='interrupted'
|
|
78
86
|
? {status:'failed',interrupted:true} : {status:'completed'})
|
|
79
87
|
const before = await readdir(join(dir,'schedules'))
|
|
80
|
-
assert.deepEqual(await names(),['recurring'])
|
|
88
|
+
assert.deepEqual(await names(),['paused','recurring'])
|
|
81
89
|
assert.deepEqual(await readdir(join(dir,'schedules')),before)
|
|
82
90
|
// A held recurring revision remains hidden even when its cursor has a later occurrence.
|
|
83
91
|
const interrupted = await scheduler.get('interrupted')
|
|
84
92
|
assert.equal('everySeconds' in interrupted.trigger && interrupted.trigger.everySeconds,60)
|
|
85
93
|
// A new schedule revision has its own occurrence; old terminal runs cannot hide it.
|
|
86
94
|
await scheduler.save({...common,id:'once',name:'once',trigger:{at:new Date(due+60_000).toISOString()}})
|
|
87
|
-
assert.deepEqual(await names(),['once','recurring'])
|
|
95
|
+
assert.deepEqual(await names(),['once','paused','recurring'])
|
|
88
96
|
})
|
|
89
97
|
|
|
90
98
|
test('prompt preview is one bounded Unicode sentence and preserves stored input', async t => {
|
|
@@ -95,15 +103,15 @@ test('prompt preview is one bounded Unicode sentence and preserves stored input'
|
|
|
95
103
|
const saved=await scheduler.save({id:'preview',name:'Preview',text,owner,
|
|
96
104
|
execution:{...execution,preset:{...execution.preset,model:'gpt-5.6-terra',effort:'high'}},
|
|
97
105
|
enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
98
|
-
const output=scheduledTasksText(await scheduler.
|
|
106
|
+
const output=scheduledTasksText(await scheduler.listTasksReadOnly([]),owner)
|
|
99
107
|
assert.match(output,/gpt-5.6-terra · high/)
|
|
100
108
|
assert.match(output,/Next · Fri, Jan 1 · 00:00 UTC/)
|
|
101
109
|
assert.equal(Array.from(output.split('\n').at(-1)!.trim()).length,140)
|
|
102
110
|
assert.ok(output.endsWith('…'))
|
|
103
111
|
assert.doesNotMatch(output,/Do not display/)
|
|
104
112
|
assert.equal((await scheduler.get(saved.id)).text,text)
|
|
105
|
-
const other=scheduledTasksText(await scheduler.
|
|
106
|
-
assert.match(other,/No
|
|
113
|
+
const other=scheduledTasksText(await scheduler.listTasksReadOnly([]),{...owner,pairedAt:'other-pairing'})
|
|
114
|
+
assert.match(other,/No scheduled tasks/)
|
|
107
115
|
assert.doesNotMatch(other,/Preview|🧪/)
|
|
108
116
|
})
|
|
109
117
|
|
|
@@ -118,7 +126,7 @@ test('a legacy invalid selection does not prevent the active menu from rendering
|
|
|
118
126
|
saved.execution.preset = {...saved.execution.preset,model:'gpt-5.6-terra',effort:'max'}
|
|
119
127
|
await writeFile(file,JSON.stringify(saved))
|
|
120
128
|
|
|
121
|
-
const active = await scheduler.
|
|
129
|
+
const active = await scheduler.listTasksReadOnly([])
|
|
122
130
|
assert.match(scheduledTasksText(active,owner),/codex \(default login\) · gpt-5.6-terra · max/)
|
|
123
131
|
})
|
|
124
132
|
|
|
@@ -128,7 +136,7 @@ test('detail view truncates long instructions and tolerates invalid timestamps',
|
|
|
128
136
|
const scheduler = new Scheduler(dir)
|
|
129
137
|
const saved = await scheduler.save({id:'long',name:'Long task',text:'/goal '+'x'.repeat(5000),owner,
|
|
130
138
|
execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
131
|
-
const active = await scheduler.
|
|
139
|
+
const active = await scheduler.listTasksReadOnly([])
|
|
132
140
|
const detail = scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!,1,1)
|
|
133
141
|
assert.match(detail,/📅 1 of 1 · Long task/)
|
|
134
142
|
assert.match(detail,/truncated; full text lives in the workspace/)
|
|
@@ -152,7 +160,7 @@ test('frequency describes saved rules without inventing a uniform interval for a
|
|
|
152
160
|
]
|
|
153
161
|
for(const [i,[trigger,label]] of cases.entries()){
|
|
154
162
|
const saved=await scheduler.save({id:`t-${i}`,name:`Task ${i}`,text:'Inspect saved work.',owner,execution,enabled:true,trigger})
|
|
155
|
-
const active=(await scheduler.
|
|
163
|
+
const active=(await scheduler.listTasksReadOnly([])).find(s=>s.id===saved.id)!
|
|
156
164
|
assert.ok(scheduledTasksText([active],owner).includes(`Schedule · ${label}\n`))
|
|
157
165
|
assert.ok(scheduledTaskDetailText(active,1).includes(`Schedule\n${label}\n`))
|
|
158
166
|
}
|
package/test/updates.test.mjs
CHANGED
|
@@ -573,10 +573,12 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
|
|
|
573
573
|
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';await atomic(path.join(jobPath(f.home,job.id),'job.json'),interrupted);
|
|
574
574
|
await atomic(path.join(f.home,'registry.lock'),{pid:second.p.pid});
|
|
575
575
|
const closed2=new Promise(r=>second.p.once('close',r));second.p.kill('SIGKILL');await closed2;
|
|
576
|
-
const third=start();t.after(()=>third.p.kill('SIGTERM'));
|
|
576
|
+
const third=start(),closed3=new Promise(r=>third.p.once('close',r));t.after(()=>third.p.kill('SIGTERM'));
|
|
577
577
|
await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));return j.status==='rolled-back';});
|
|
578
578
|
assert.equal((await read(path.join(f.home,'config.json'))).packageRoot,f.old);
|
|
579
|
-
|
|
579
|
+
// A rolled-back main update reloads automatically once native work is idle.
|
|
580
|
+
await wait(()=>third.p.exitCode!==null||third.p.signalCode!==null);
|
|
581
|
+
assert.equal(await closed3,0,third.output());
|
|
580
582
|
});
|
|
581
583
|
|
|
582
584
|
test('queued upgrades keep admission open while busy and release a raced or aborted pause',async t=>{
|
|
@@ -760,3 +762,35 @@ test('changed single-file broker binding retains full native drain and broker re
|
|
|
760
762
|
assert.equal((await perform(f.home,job,{...r,refreshPluginBroker:true})).status,'completed');
|
|
761
763
|
assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,1);
|
|
762
764
|
});
|
|
765
|
+
|
|
766
|
+
test('handoff-compatible core upgrade builds with native work active and retires only at activation',async t=>{
|
|
767
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f)
|
|
768
|
+
await fs.mkdir(path.join(f.agent.controlDir,'host-executor'),{recursive:true})
|
|
769
|
+
const claim=path.join(f.agent.controlDir,'host-executor','r_native.running.json')
|
|
770
|
+
await fs.writeFile(claim,'active native occurrence')
|
|
771
|
+
r.preserveRuns=true;r.retireHost=async()=>{assert.equal((await read(path.join(f.agent.controlDir,'upgrade-pause.json'))).id,job.id);r.calls.push(['retireHost'])}
|
|
772
|
+
const execute=r.execute
|
|
773
|
+
r.execute=async(command,args,options)=>{
|
|
774
|
+
if(args.includes('build')){await fs.access(claim);await assert.rejects(fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')),{code:'ENOENT'});await assert.rejects(fs.access(path.join(f.home,'registry.lock')),{code:'ENOENT'})}
|
|
775
|
+
if(args.includes('stop')){
|
|
776
|
+
const backup=path.join(jobPath(f.home,job.id),'backup');
|
|
777
|
+
assert.equal(await fs.readFile(path.join(backup,'workspace','TOOLS.md'),'utf8'),'My notes\n','state backup finishes before relay shutdown');
|
|
778
|
+
await fs.access(path.join(backup,'control'));
|
|
779
|
+
await assert.rejects(fs.access(path.join(backup,'control','host-executor')),{code:'ENOENT'});
|
|
780
|
+
}
|
|
781
|
+
return execute(command,args,options)
|
|
782
|
+
}
|
|
783
|
+
assert.equal((await withUpgrade(f.agent.controlDir,job,new AbortController().signal,()=>perform(f.home,job,r),{preserveRuns:true,isIdle:async()=>false})).status,'completed')
|
|
784
|
+
assert.equal(r.calls.filter(c=>c[0]==='retireHost').length,1)
|
|
785
|
+
assert.ok(!r.calls.some(c=>c[0]==='stopHost'))
|
|
786
|
+
assert.equal(await fs.readFile(claim,'utf8'),'active native occurrence')
|
|
787
|
+
await assert.rejects(fs.access(path.join(f.agent.controlDir,'upgrade-pause.json')),{code:'ENOENT'})
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
test('failed handoff-compatible activation retires the candidate and rolls back without stopping native runs',async t=>{
|
|
791
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f,{fail:(_command,args)=>args.includes('up')})
|
|
792
|
+
r.preserveRuns=true;r.retireHost=async()=>r.calls.push(['retireHost'])
|
|
793
|
+
assert.equal((await perform(f.home,job,r)).status,'rolled-back')
|
|
794
|
+
assert.equal(r.calls.filter(c=>c[0]==='retireHost').length,2)
|
|
795
|
+
assert.ok(!r.calls.some(c=>c[0]==='stopHost'))
|
|
796
|
+
})
|