@jc_stack/ez-agents 0.1.0-beta.50 → 0.1.0-beta.52

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.
@@ -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 active scheduled tasks/)
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=>fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){${state}}\n`,{mode:0o700});
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});
@@ -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.listActiveReadOnly(await runs.list())
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.listActiveReadOnly(await runs.list())).map(s=>s.id).sort()
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.listActiveReadOnly(await runs.list())).find(s=>s.id==='once')!
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.listActiveReadOnly(await runs.list())).find(s=>s.id==='once')!.runState,'running')
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.listActiveReadOnly([]),owner)
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.listActiveReadOnly([]),{...owner,pairedAt:'other-pairing'})
106
- assert.match(other,/No active scheduled tasks/)
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.listActiveReadOnly([])
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.listActiveReadOnly([])
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.listActiveReadOnly([])).find(s=>s.id===saved.id)!
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
  }
@@ -14,7 +14,7 @@ import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manage
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { TOOLS_LINE } from '../src/tools-line.mjs';
16
16
  import { status as runtimeStatus } from '../src/updates/status.mjs';
17
- import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
17
+ import { idle, executorBindingArchive, executorVolumes, providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade, withUpgrade } from '../src/updates/supervisor.mjs';
18
18
  const exec=promisify(execFile);
19
19
 
20
20
  test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
@@ -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
- const closed3=new Promise(r=>third.p.once('close',r));third.p.kill('SIGTERM');assert.equal(await closed3,0,third.output());
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,62 @@ 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
+ })
797
+
798
+
799
+ test('missing executor startup ignores queued requests but never live native work',async t=>{
800
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-admission-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
801
+ const dir=path.join(root,'host-executor');await fs.mkdir(dir);
802
+ await fs.writeFile(path.join(dir,'queued.request.json'),'{}');
803
+ assert.equal(await idle(root),false);assert.equal(await idle(root,{pendingRequests:false}),true);
804
+ await fs.writeFile(path.join(dir,'live.running.json'),'{}');assert.equal(await idle(root,{pendingRequests:false}),false);
805
+ assert.equal(await fs.readFile(path.join(dir,'queued.request.json'),'utf8'),'{}');
806
+ await fs.rm(path.join(dir,'live.running.json'));
807
+ const server=createServer(socket=>socket.on('data',chunk=>{const req=JSON.parse(chunk.toString());socket.end(JSON.stringify({id:req.id,ok:true,result:{running:1}})+'\n');}));
808
+ await new Promise(resolve=>server.listen(path.join(root,'delivery.sock'),resolve));t.after(()=>new Promise(resolve=>server.close(resolve)));
809
+ assert.equal(await idle(root,{pendingRequests:false}),false);
810
+ });
811
+ test('executor preserves exact named volume identities and rejects missing declarations',()=>{
812
+ const worker={volumes:[{type:'volume',source:'registered-whatsapp-ipc'},{type:'bind',source:'/private/workspace'}]};
813
+ assert.deepEqual(executorVolumes({volumes:{'registered-whatsapp-ipc':{name:'existing-private-ipc'}}},worker),{'registered-whatsapp-ipc':{name:'existing-private-ipc',external:true}});
814
+ assert.throws(()=>executorVolumes({},worker),/Missing executor volume/);
815
+ });
816
+ test('executor archive extracts its exact private binding without host ownership changes',async t=>{
817
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-binding-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
818
+ const binding={agents:[{workspace:'/private/work',binDir:'/app/bin'}]},file=path.join(root,'binding.tar');
819
+ await fs.writeFile(file,executorBindingArchive(binding,1000),{mode:0o600});
820
+ await exec('tar',['-xf',file,'-C',root]);
821
+ const extracted=path.join(root,'executor.json');assert.deepEqual(await read(extracted),binding);assert.equal((await fs.stat(extracted)).mode&0o777,0o440);
822
+ for(const invalid of [-1,NaN,1.5,'1000',0o10000000])assert.throws(()=>executorBindingArchive(binding,invalid),/Invalid executor group/);
823
+ });