@jc_stack/ez-agents 0.1.0-beta.39 → 0.1.0-beta.41

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/AGENTS.md CHANGED
@@ -121,7 +121,7 @@ round merely to seek more criticism, tests or hypothetical edge cases.
121
121
 
122
122
  ## 6. Concurrency & Workspace Invariants
123
123
  - **1 Writer Job per Workspace:** The agent's Markdown folder (`./agent/`) is its mind. Never run concurrent background processes writing to the same workspace simultaneously.
124
- - Main-conversation jobs queue sequentially in the relay's memory ledger; the delivery socket carries every cross-process enqueue/receipt, never control/ files. Scheduled/background work uses separate task directories under the control directory and native CLI sessions (up to four alongside chat). Never share a mutable task directory. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
124
+ - Main-conversation and owner-scheduled jobs write the same mind workspace one at a time; the delivery socket carries every cross-process enqueue/receipt, never control/ files. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
125
125
 
126
126
  ## 7. Fail-Closed Authority (Channel Access ≠ Execution)
127
127
  - Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.41
4
+
5
+ - Run owner-scheduled tasks from the bound agent workspace so native instructions
6
+ and relative context are available, while serializing writes with owner turns.
7
+
8
+ ## 0.1.0-beta.40
9
+
10
+ - Apply eligible npm releases under each agent's saved automatic update policy
11
+ through the existing guarded updater. Queue one update at a time, preserve
12
+ manual policies, and wait for a newer version after a failed automatic update.
13
+
3
14
  ## 0.1.0-beta.39
4
15
 
5
16
  - Render completed, binding-scoped application replies as WAV speech with
package/README.md CHANGED
@@ -327,6 +327,6 @@ main/plugin replacement. See [upgrade setup, tools and recovery](docs/upgrades.m
327
327
 
328
328
  The core `ezenciel-agents-schedule` CLI accepts instruction text for one-off dates,
329
329
  intervals and timezone-aware cron schedules. `create --now` delegates a task to a
330
- separate CLI session so the owner conversation remains available. Long work has
330
+ separate CLI session queued with owner turns in the bound workspace. Long work has
331
331
  no production wall-clock timeout; goals and subagents remain native executor
332
332
  features. See [scheduling, recovery and QA](docs/scheduling.md).
@@ -3,11 +3,12 @@
3
3
  Owner input passes unchanged to the selected engine. The agent decides when to
4
4
  delegate or schedule long work to remain available. ez queues foreground input
5
5
  while a foreground run is active; it does not create a separate busy-reply agent.
6
- Independent scheduled task directories retain deterministic writer isolation.
6
+ Owner-scheduled turns use the bound mind workspace under the same single-writer
7
+ limit as foreground turns.
7
8
 
8
- Background tasks receive literal task text. Their directories contain no generated
9
- role instructions or copied identity files; native workspace instructions and
10
- existing Markdown provide context. The agent chooses what to read and when to use
9
+ Background tasks receive literal task text. Their native sessions contain no
10
+ generated role instructions or copied identity files; native workspace instructions
11
+ and existing Markdown provide context. The agent chooses what to read and when to use
11
12
  the message CLI. Native final text is not automatically delivered to Telegram.
12
13
 
13
14
  Restricted correspondence receives a typed activation event and scoped tools.
@@ -40,18 +40,18 @@ available through `ezenciel-agents-schedule context`.
40
40
  ## Execution and authority
41
41
 
42
42
  Due occurrences enter the durable queue with stable IDs and literal task text.
43
- Background runs use fresh native sessions in `work/tasks/RUN_ID/`. No identity
44
- files or role instructions are generated there. Existing workspace Markdown
45
- provides context; the engine chooses what to read. Codex uses `AGENTS.md` or `.git`
46
- as its native project-root marker, so a nested task sees the existing agent scope. Task folders remain for
47
- inspection and artifact delivery.
48
-
49
- One writer runs per task directory. Up to four background tasks can run alongside
50
- the main conversation. Foreground inputs queue while a foreground turn runs;
43
+ Owner-scheduled runs use fresh native sessions in the bound agent workspace.
44
+ No identity files or role instructions are generated. Existing workspace Markdown
45
+ provides context, including native `AGENTS.md` discovery and relative links; the
46
+ engine chooses what to read. Per-run native state stays under `control/`.
47
+
48
+ One writer runs in the agent workspace: owner schedules and foreground turns
49
+ queue behind each other. Foreground inputs queue while another turn runs;
51
50
  ez does not create another reply agent. The agent can delegate or schedule long
52
51
  work and return to chat. It decides when to send through the message CLI.
53
52
  A recurring schedule has at most one pending or active occurrence. Shared
54
- provider resources still need writer coordination.
53
+ provider resources still need writer coordination. The former four background
54
+ slots are no longer used for owner schedules.
55
55
 
56
56
  Production relay/host execution has no wall-clock timeout. The old
57
57
  `EZ_EXECUTOR_TIMEOUT_SECONDS` setting is ignored. Individual network/tool waits
@@ -70,7 +70,7 @@ goal is not reported as successful. Native RPC requests have a response deadline
70
70
  running tasks do not.
71
71
  Each scheduled task has its own Codex state under `control/cli/codex/tasks/RUN_ID`,
72
72
  with a snapshot of the agent's Codex configuration and the existing auth link.
73
- Foreground chat and background tasks do not initialize or migrate one shared
73
+ Foreground chat and scheduled tasks do not initialize or migrate one shared
74
74
  native database concurrently.
75
75
 
76
76
  The foreground chat still uses `codex exec`. That invocation exits after one
package/docs/upgrades.md CHANGED
@@ -12,12 +12,13 @@ automatic channel for core and plugins without a saved policy. Existing explicit
12
12
  stable or manual policies are preserved. The owner may select either per target.
13
13
  The main target and installed plugins version independently.
14
14
 
15
- The agent owns release review, policy decisions and communication. The host
16
- supervisor owns interruption-safe replacement. There is one host service per
17
- agent, not a second agent or an additional updater daemon. Its replaceable child
18
- runs the normal CLI transport. It checks npm every six hours while running and
19
- records automatic-channel changes; discovery never wakes the agent or injects
20
- wrapper state into a session. Updates stay owner-invoked through `ez updates`.
15
+ The owner chooses the saved update policy. The host supervisor owns
16
+ interruption-safe replacement. There is one host service per agent, not a second
17
+ agent or an additional updater daemon. Its replaceable child runs the normal CLI
18
+ transport. It checks npm every six hours while running and queues one eligible
19
+ release at a time when the policy is automatic. Discovery and replacement never
20
+ wake the agent or inject wrapper state into a session. Manual policies retain
21
+ owner-invoked updates through `ez updates`.
21
22
  Checks use the installed scoped npm identity; failures are
22
23
  visible in `updates check` and private `tools/updates/available.json`. Plugins marked
23
24
  `private: true` in their package metadata are reported as local-source updates
@@ -265,5 +266,7 @@ execution authority.
265
266
 
266
267
  Update discovery runs independently of active host work. A discovery failure is
267
268
  logged locally and retried at the next regular six-hour check; it does not stop
268
- the host or create a repair task. Inspect `ez updates check` for target diagnostics.
269
+ the host or create a repair task. An automatically failed or rolled-back version
270
+ is not retried until a newer version appears or the owner explicitly retries.
271
+ Inspect `ez updates check` and `ez updates status` for target diagnostics.
269
272
  Actual update transactions retain their existing admission, drain and rollback rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.39",
3
+ "version": "0.1.0-beta.41",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -11,7 +11,6 @@ import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider,
11
11
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
12
  import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
13
13
  import type { ChildProcess } from 'node:child_process'
14
- import { taskWorkspace } from './task-workspace.js'
15
14
  import { packageVersion } from './version.js'
16
15
  import { installedPluginVersions } from './software-status.js'
17
16
  import { processSnapshot } from './process-tree.js'
@@ -52,6 +51,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
52
51
  let catalogRefresh: Promise<void> | undefined
53
52
  const locks: string[] = []
54
53
  const sharedWorkspaces = new Map<HostBinding, string>()
54
+ const activeWorkspaces = new Set<HostBinding>()
55
55
  const additionalWorkspaces = new Map<HostBinding, string[]>()
56
56
  const catalog = async (agent: HostBinding) => {
57
57
  const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
@@ -158,11 +158,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
158
158
  }
159
159
  const sharedWorkspace=sharedWorkspaces.get(agent)
160
160
  const base=path.join(directory,id)
161
- const releaseWorkspace = !run?.scheduled && agent.toolsHome
161
+ if (activeWorkspaces.has(agent)) continue
162
+ const releaseWorkspace = agent.toolsHome
162
163
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
163
- if (!run?.scheduled && agent.toolsHome && !releaseWorkspace) continue
164
+ if (agent.toolsHome && !releaseWorkspace) continue
165
+ activeWorkspaces.add(agent)
164
166
  try { await rename(base+'.request.json',base+'.running.json') }
165
- catch (error) { await releaseWorkspace?.(); throw error }
167
+ catch (error) { activeWorkspaces.delete(agent); await releaseWorkspace?.(); throw error }
166
168
  const task=(async()=>{
167
169
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
168
170
  let cancellation: ReturnType<typeof setInterval> | undefined
@@ -193,7 +195,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
193
195
  // transient native-client probe failure must not reject a model
194
196
  // that the host just advertised to the relay and application.
195
197
  if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
196
- const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
198
+ const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
197
199
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
198
200
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
199
201
  promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
@@ -218,7 +220,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
218
220
  await rm(base+'.process.json',{force:true})
219
221
  await rm(base+'.cancel',{force:true})
220
222
  active.delete(base)
221
- await releaseWorkspace?.()
223
+ try { await releaseWorkspace?.() } finally { activeWorkspaces.delete(agent) }
222
224
  }
223
225
  })()
224
226
  tasks.add(task); void task.finally(()=>tasks.delete(task))
package/src/index.ts CHANGED
@@ -12,7 +12,6 @@ import { randomUUID } from 'node:crypto'
12
12
  import { stat } from 'node:fs/promises'
13
13
  import { Scheduler } from './scheduler.js'
14
14
  import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
15
- import { taskWorkspace } from './task-workspace.js'
16
15
  import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
17
16
  import {removeTaskAttachments} from './task-attachments.js'
18
17
  import { dirname, join, basename } from 'node:path'
@@ -206,7 +205,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
206
205
  return
207
206
  }
208
207
  if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
209
- if (run.scheduled ? background.size >= 4 : activeChild) return
208
+ // Owner turns and schedules write the same mind. Keep one native writer.
209
+ if (activeChild || background.size) return
210
210
  let texts = run.texts
211
211
  if (run.external) {
212
212
  // Availability failures leave durable queued work for a later check.
@@ -240,7 +240,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
240
240
  : await control.executionSession(started.execution!)
241
241
  const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
242
242
  const { child, cleanup } = await launch(texts, {
243
- workspace: run.scheduled ? await taskWorkspace(config.controlDir,run.id) : config.workspace,
243
+ workspace: config.workspace,
244
244
  timeoutMs: config.executorTimeoutMs,
245
245
  repairEnabled: config.repairEnabled,
246
246
  runId: started.id,
@@ -319,7 +319,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
319
319
  }
320
320
  })
321
321
  console.info('run ended', { run_id: started.id, code })
322
- const next = await runs.nextQueued(false)
322
+ const next = await runs.nextQueued()
323
323
  if (next && !shuttingDown) await startJob(next)
324
324
  })().catch((error) => console.error('Run completion failed', error.message))
325
325
  })
@@ -336,7 +336,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
336
336
  if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
337
337
  setImmediate(() => {
338
338
  void runs
339
- .nextQueued(false)
339
+ .nextQueued()
340
340
  .then((next) => next && startJob(next))
341
341
  .catch(console.error)
342
342
  })
@@ -571,7 +571,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
571
571
  ])
572
572
  console.info('run reaction sent', { run_id: item.runId, emoji })
573
573
  } else if (item.type === 'document' && item.documentPath) {
574
- const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.controlDir,origin.id) : config.workspace, item.documentPath)
574
+ const docPath = await workspaceFile(config.workspace, item.documentPath)
575
575
  await paceSend()
576
576
  await authorizeChannelDelivery()
577
577
  attemptedDelivery = true
@@ -1206,7 +1206,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1206
1206
  }
1207
1207
  const currentRunning = await runs.running(false)
1208
1208
  if (!currentRunning) {
1209
- const pendingRun = await runs.nextQueued(false)
1209
+ const pendingRun = await runs.nextQueued()
1210
1210
  if (pendingRun) {
1211
1211
  console.info('Processing queued run on startup:', pendingRun.id)
1212
1212
  void startJob(pendingRun)
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { atomic, locked } from '../plugins/manager.mjs';
6
6
  import { callDeliverySocket } from '../delivery-socket-client.mjs';
7
- import { state, read, jobs, jobPath, check, missing, cleanupStaleBackups } from './control.mjs';
7
+ import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
8
8
  import { perform, environment } from './runtime.mjs';
9
9
 
10
10
  const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
@@ -28,6 +28,22 @@ export async function idle(control) {
28
28
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
29
29
  return !status||status.running===0;
30
30
  }
31
+ export async function queueAutomatic(home,available) {
32
+ const existing=await jobs(home);
33
+ if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
34
+ for(const candidate of available) {
35
+ if(!candidate.newer||!candidate.policy?.automatic||!candidate.available)continue;
36
+ // A failed release needs a newer version or an explicit owner retry.
37
+ if(existing.some(job=>job.target===candidate.target&&job.version===candidate.available&&['failed','rolled-back'].includes(job.status)))continue;
38
+ try {
39
+ const job=await prepare(home,candidate.target,{release:candidate.available});
40
+ return await submit(home,job.id,true);
41
+ }catch {
42
+ console.error(`Automatic update of ${candidate.target} failed; inspect ez updates check/status.`);
43
+ }
44
+ }
45
+ return null;
46
+ }
31
47
  export async function supervise(deployment,signal,{discover=check}={}) {
32
48
  const host=await read(path.join(deployment,'host-executor.json'));
33
49
  if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
@@ -96,12 +112,16 @@ export async function supervise(deployment,signal,{discover=check}={}) {
96
112
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
97
113
  if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
98
114
  } finally {await fs.rm(pause,{force:true});}
99
- if(result?.status==='completed'&&pending.target==='main')return;
115
+ if(result?.status==='completed') {
116
+ if(pending.target==='main')return;
117
+ nextCheck=0;
118
+ }
100
119
  }
101
120
  if(Date.now()>=nextCheck) {
102
121
  nextCheck=Date.now()+6*60*60*1000;
103
122
  try {
104
123
  const results=await discover(home);await atomic(path.join(directory,'available.json'),results);
124
+ await queueAutomatic(home,results);
105
125
  } catch {
106
126
  // Discovery is optional; its failure must not terminate the host.
107
127
  // Do not expose registry response bodies or credentials in logs.
@@ -284,18 +284,23 @@ test('one installed CLI executes two agent bindings with separate minds and sani
284
284
  assert.match(output, /"stream":"exit","code":0/)
285
285
  return output.trim().split('\n').map(line=>JSON.parse(line))
286
286
  }
287
- // A running scheduled engine does not reserve either its agent or its
288
- // shared workspace, including another binding through a filesystem alias.
289
- const [,,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(otherDirectory,'r_other_shared'),completed(directory,'tg_42')])
290
- const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
291
- assert.match(chat.args.at(-1),/^Chat while scheduled work runs/)
292
- assert.match(chat.args.at(-1),/ezenciel-agents-message/)
287
+ // Another agent remains independent; this agent's queued schedule and chat
288
+ // wait behind its active owner schedule in the same mind.
289
+ await completed(otherDirectory,'r_other_shared')
290
+ assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
291
+ assert.ok(await readFile(path.join(directory,'tg_42.request.json')))
293
292
  assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
294
293
  await readFile(path.join(directory,'r_hold.running.json'))
295
294
  await writeFile(path.join(directory,'r_hold.cancel'),'')
296
295
  let heldOutput=''
297
296
  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))}
298
297
  assert.match(heldOutput, /"stream":"exit","code":1/)
298
+ const [scheduleEvents,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(directory,'tg_42')])
299
+ const scheduled=JSON.parse(scheduleEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
300
+ const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
301
+ assert.equal(scheduled.cwd,await realpath(agents[0].workspace))
302
+ assert.match(chat.args.at(-1),/^Chat while scheduled work runs/)
303
+ assert.match(chat.args.at(-1),/ezenciel-agents-message/)
299
304
  } finally {
300
305
  abort.abort();await server
301
306
  EXECUTOR_REGISTRY.grok.command=old
@@ -11,11 +11,12 @@ import { RunStore } from '../src/runs.js'
11
11
 
12
12
  const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
13
13
  const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
14
- test('host transport reserves separate task and main lanes, pins directories, and cancels only its target',async()=>{
14
+ test('host transport serializes owner schedules and chat in the bound mind',async()=>{
15
15
  const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
16
16
  await mkdir(workspace);await mkdir(controlDir)
17
+ await writeFile(join(workspace,'AGENTS.md'),'bound-owner-instructions')
17
18
  const script=join(root,'fixture.mjs')
18
- await writeFile(script,`console.log(JSON.stringify({cwd:process.cwd(),token:process.env.TELEGRAM_BOT_TOKEN,run:process.env.EZ_RUN_ID}));if(process.env.EZ_RUN_ID.startsWith('r_schedule_'))setInterval(()=>{},1000);`)
19
+ await writeFile(script,`import {readFileSync} from 'node:fs';console.log(JSON.stringify({cwd:process.cwd(),agents:readFileSync('AGENTS.md','utf8'),token:process.env.TELEGRAM_BOT_TOKEN,run:process.env.EZ_RUN_ID}));if(process.env.EZ_RUN_ID.startsWith('r_schedule_'))setInterval(()=>{},1000);`)
19
20
  const old={...EXECUTOR_REGISTRY.grok},token=process.env.TELEGRAM_BOT_TOKEN
20
21
  EXECUTOR_REGISTRY.grok.command=process.execPath;EXECUTOR_REGISTRY.grok.buildArgs=()=>[script]
21
22
  process.env.TELEGRAM_BOT_TOKEN='never-in-child'
@@ -31,21 +32,29 @@ test('host transport reserves separate task and main lanes, pins directories, an
31
32
  await submit(id)
32
33
  await until(async()=>Boolean(await exists(id+'.process.json')))
33
34
  await submit('tg_1')
34
- await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
35
+ await new Promise(resolve=>setTimeout(resolve,300))
36
+ assert.ok(await exists('tg_1.request.json'))
37
+ assert.equal(await exists('tg_1.events'),'')
35
38
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
36
39
  const scheduled=JSON.parse(stdout(await exists(id+'.events')))
37
- const main=JSON.parse(stdout(await exists('tg_1.events')))
38
- assert.equal(scheduled.cwd,await realpath(join(controlDir,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
39
- assert.equal(scheduled.token,undefined);assert.equal(main.token,undefined)
40
+ assert.equal(scheduled.cwd,await realpath(workspace))
41
+ assert.equal(scheduled.agents,'bound-owner-instructions')
42
+ assert.equal(scheduled.token,undefined)
40
43
  // An unknown scheduled run must not unwind the shared host service.
41
44
  await submit('r_schedule_corrupt')
42
45
  await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
43
46
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
44
47
  await ownerRun(controlDir,'tg_2')
45
48
  await submit('tg_2')
46
- await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
49
+ assert.ok(await exists('tg_2.request.json'))
47
50
  await writeFile(join(dir,id+'.cancel'),'')
48
51
  await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
52
+ await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
53
+ await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
54
+ const main=JSON.parse(stdout(await exists('tg_1.events')))
55
+ assert.equal(main.cwd,await realpath(workspace))
56
+ assert.equal(main.agents,'bound-owner-instructions')
57
+ assert.equal(main.token,undefined)
49
58
  }finally{
50
59
  abort.abort();await server
51
60
  Object.assign(EXECUTOR_REGISTRY.grok,old)
@@ -44,7 +44,7 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
44
44
  }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
45
45
  })
46
46
 
47
- test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
47
+ test('owner chat queues behind a scheduled CLI in the same workspace; targeted cancellation',async()=>{
48
48
  const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
49
49
  const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
50
50
  const replies:string[]=[], workspaces:string[]=[]
@@ -68,27 +68,20 @@ test('chat replies through the real ingress/outbox while scheduled CLI remains a
68
68
  assert.equal(background.status,'running')
69
69
  const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
70
70
  await relay.bot.handleUpdate(update);await relay.drainInbox(true);await relay.drainOutbox()
71
- assert.ok(replies.includes('323'))
71
+ assert.ok(!replies.includes('323'))
72
72
  assert.equal((await runs.get(background.id))?.status,'running')
73
73
  assert.equal(children[0].exitCode,null)
74
- assert.equal(new Set(workspaces).size,2)
74
+ assert.deepEqual(workspaces,[dir])
75
75
  // Pausing future dispatch doesn't kill active work; cancelling this run does.
76
76
  await scheduler.enable('slow',false);await relay.drainSources()
77
77
  assert.equal(children[0].exitCode,null)
78
78
  await scheduler.cancel(background.id);await relay.drainSources()
79
79
  await until(async()=> (await runs.get(background.id))?.status==='cancelled')
80
+ await until(async()=>children.length===2)
81
+ await relay.drainOutbox()
82
+ assert.ok(replies.includes('323'))
83
+ assert.deepEqual(workspaces,[dir,dir])
80
84
  assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
81
- for(let n=0;n<5;n++)await scheduler.save({id:'pool_'+n,name:'Pool',text:'Long work',trigger:{at:new Date(Date.now()+2000).toISOString()},enabled:true,owner,execution})
82
- await scheduler.tick(owner,runs,Date.now()+3000);await relay.drainSources()
83
- const queued=(await runs.list()).find(r=>r.scheduled && r.status==='queued')!
84
- assert.ok(queued)
85
- assert.equal((await runs.list()).filter(r=>r.scheduled && r.status==='running').length,4)
86
- await scheduler.enable(queued.scheduled!.id,false);await relay.drainSources()
87
- assert.equal((await runs.get(queued.id))?.status,'queued')
88
- await scheduler.enable(queued.scheduled!.id,true);await scheduler.cancel(queued.id);await relay.drainSources()
89
- assert.equal((await runs.get(queued.id))?.status,'cancelled')
90
- await relay.bot.handleUpdate({...update,update_id:124,message:{...update.message!,message_id:124,text:'/stop'}} as Update)
91
- await until(async()=>!(await runs.list()).some(r=>r.scheduled && r.status==='running'))
92
85
  }finally{
93
86
  await relay.stop()
94
87
  for(const child of children)if(child.exitCode===null && child.signalCode===null)await once(child,'close')
@@ -1,13 +1,12 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, writeFile, readFile, stat, symlink, mkdir } from 'node:fs/promises'
3
+ import { mkdtemp, rm, writeFile, readFile, stat } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { randomUUID } from 'node:crypto'
7
7
  import { Scheduler, scheduledRunId } from '../src/scheduler.js'
8
8
  import { RunStore } from '../src/runs.js'
9
9
  import { nextOccurrence, validateTrigger, type Trigger } from '../src/schedule-time.js'
10
- import { taskWorkspace } from '../src/task-workspace.js'
11
10
 
12
11
  const next=(t:Trigger,after:string)=>{
13
12
  const at=nextOccurrence(validateTrigger(t),Date.parse(after));return at===null ? null : new Date(at).toISOString()
@@ -76,19 +75,6 @@ test('pause, edit, removal, owner revocation, corrupt records and traversal fail
76
75
  await assert.rejects(f.scheduler.cancel('../bad'))
77
76
  await f.scheduler.cancel(run.id);assert.equal(await f.scheduler.cancelled(run.id),true)
78
77
  })
79
- test('task workspaces are distinct and cannot escape through symlinks',async t=>{
80
- const f=await fixture(t)
81
- await writeFile(join(f.dir,'private.md'),'Owner context')
82
- const first=await taskWorkspace(f.dir,'r_one'),second=await taskWorkspace(f.dir,'r_two')
83
- assert.notEqual(first,second)
84
- await assert.rejects(readFile(join(first,'private.md'),'utf8'),{code:'ENOENT'})
85
- await assert.rejects(readFile(join(first,'AGENTS.md'),'utf8'),{code:'ENOENT'})
86
- await assert.rejects(taskWorkspace(f.dir,'../escape'))
87
- const other=join(f.dir,'other');await mkdir(other)
88
- await symlink(other,join(f.dir,'work/tasks/r_link'))
89
- await assert.rejects(taskWorkspace(f.dir,'r_link'))
90
- })
91
-
92
78
  test('startup quarantines an interrupted spawn before PID persistence; explicit edit releases its schedule',async t=>{
93
79
  const f=await fixture(t),s=await f.scheduler.save({...f.input,trigger:{everySeconds:60,start:new Date(f.now).toISOString()}})
94
80
  await f.scheduler.tick(f.owner,f.runs,f.now)
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory } from '../s
13
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
14
14
  import { bindUpdates } from '../src/updates/binding.mjs';
15
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
16
- import { providerEnvironment } from '../src/updates/supervisor.mjs';
16
+ import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
17
17
  const exec=promisify(execFile);
18
18
 
19
19
  test('main upgrade backup omits a live plugin socket and keeps private files',async t=>{
@@ -442,6 +442,29 @@ test('npm candidates verify exact version and integrity; automatic policy is enf
442
442
  pkg.version='0.1.2';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/version mismatch/);
443
443
  });
444
444
 
445
+ test('supervisor queues an eligible release once and preserves manual and failed-version gates',async t=>{
446
+ const f=await fixture(t),data=await fs.readFile(await f.pack());
447
+ const {createHash}=await import('node:crypto');
448
+ const pkg={name:'@ez-test/example',version:'0.1.1',dist:{tarball:'https://registry.npmjs.org/@ez-test/example/-/example-0.1.1.tgz',integrity:'sha512-'+createHash('sha512').update(data).digest('base64')}};
449
+ const original=globalThis.fetch;
450
+ globalThis.fetch=async url=>new Response(String(url).endsWith('.tgz')?data:JSON.stringify(String(url).endsWith('/0.1.1')?pkg:{name:pkg.name,'dist-tags':{latest:pkg.version},versions:{[pkg.version]:pkg}}));
451
+ t.after(()=>globalThis.fetch=original);
452
+ await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
453
+ await command(f.home,['policy','main','manual']);
454
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
455
+ assert.deepEqual(await jobs(f.home),[]);
456
+ await command(f.home,['policy','main','beta']);
457
+ const queued=await queueAutomatic(f.home,await command(f.home,['check']));
458
+ assert.equal(queued.status,'queued');assert.equal(queued.automatic,true);
459
+ assert.equal(queued.origin.type,'npm');assert.equal(queued.sha256,digest(data));
460
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
461
+ assert.equal((await jobs(f.home)).length,1);
462
+ queued.status='failed';await atomic(path.join(jobPath(f.home,queued.id),'job.json'),queued);
463
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
464
+ assert.equal((await jobs(f.home)).length,1);
465
+ await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'update-attention.json')),error=>error.code==='ENOENT');
466
+ });
467
+
445
468
  test('update discovery follows latest while preserving legacy beta and stable-only policies',async t=>{
446
469
  const {registryCandidate}=await import('../src/updates/artifact.mjs');
447
470
  const name='@ez-test/example',pkg=v=>({name,version:v});
@@ -1,14 +0,0 @@
1
- import { mkdir, lstat } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
- import { assertId } from './identity.js'
4
-
5
- // Fixed, agent-bound path under the control directory, never the workspace
6
- // mind folder and never a caller-provided cwd. No shared mutable task files.
7
- export async function taskWorkspace(baseDir: string, id: string): Promise<string> {
8
- const dirs=[join(baseDir,'work'),join(baseDir,'work','tasks'),join(baseDir,'work','tasks',assertId(id))]
9
- for(const dir of dirs){
10
- await mkdir(dir,{recursive:true,mode:0o700})
11
- if(!(await lstat(dir)).isDirectory())throw new Error('Task workspace must not be a symlink')
12
- }
13
- return dirs[2]
14
- }