@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 +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/docs/responsive-channels.md +5 -4
- package/docs/scheduling.md +10 -10
- package/docs/upgrades.md +10 -7
- package/package.json +1 -1
- package/src/host-executor.ts +8 -6
- package/src/index.ts +7 -7
- package/src/updates/supervisor.mjs +22 -2
- package/test/host-executor.test.ts +11 -6
- package/test/scheduler-host.test.ts +16 -7
- package/test/scheduler-relay.test.ts +7 -14
- package/test/scheduler.test.ts +1 -15
- package/test/updates.test.mjs +24 -1
- package/src/task-workspace.ts +0 -14
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
|
|
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
|
|
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
|
-
|
|
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
|
|
9
|
-
role instructions or copied identity files; native workspace instructions
|
|
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.
|
package/docs/scheduling.md
CHANGED
|
@@ -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
|
-
|
|
44
|
-
files or role instructions are generated
|
|
45
|
-
provides context
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
16
|
-
|
|
17
|
-
agent
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
wrapper state into a session.
|
|
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.
|
|
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.
|
|
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.",
|
package/src/host-executor.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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'
|
|
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
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
assert.
|
|
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
|
|
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
|
|
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
|
-
|
|
38
|
-
assert.equal(scheduled.
|
|
39
|
-
assert.equal(scheduled.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
|
-
|
|
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
|
|
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.
|
|
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')
|
package/test/scheduler.test.ts
CHANGED
|
@@ -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
|
|
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)
|
package/test/updates.test.mjs
CHANGED
|
@@ -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});
|
package/src/task-workspace.ts
DELETED
|
@@ -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
|
-
}
|