@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.
@@ -3,7 +3,7 @@ import { Tasks } from './tasks.js'
3
3
  import { ControlStore } from './control-state.js'
4
4
  import { authorizeRun, readRun } from './delivery-socket.js'
5
5
  import { runPromptSuffix } from './prompt-suffix.js'
6
- import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
6
+ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath, utimes, stat } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
@@ -42,13 +42,16 @@ export const refreshAgentCatalog = async (agent: HostBinding,
42
42
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
43
43
 
44
44
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
45
- readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>) => {
45
+ readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>, retire?: AbortSignal) => {
46
46
  if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
47
- if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
47
+ if (installation.isolation === 'isolated' && process.env.EZ_CONTAINER_EXECUTOR !== '1') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
48
48
  resolveExecutor(installation.cli)
49
49
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
50
50
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
51
51
  throw new Error('Each agent requires a separate workspace and control directory')
52
+ const containerId = process.env.EZ_CONTAINER_EXECUTOR === '1' ? process.env.HOSTNAME : undefined
53
+ const hostStarted = await processStart(process.pid)
54
+ let retiring = false
52
55
  const active = new Map<string, ChildProcess>()
53
56
  // A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
54
57
  const claimed = new Set<string>()
@@ -74,9 +77,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
74
77
  const freshCatalog = readCatalog ?? catalog
75
78
  const beat = async (agent: HostBinding) => {
76
79
  const directory = path.join(agent.controlDir, 'host-executor')
77
- await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
80
+ await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, ...(process.env.EZ_CONTAINER_EXECUTOR === '1' ? {containerExecutor:true,containerId} : {}), plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
78
81
  await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
79
82
  }
83
+ const recoverClaims = async (agent: HostBinding) => {
84
+ const directory=path.join(agent.controlDir,'host-executor')
85
+ for (const file of await readdir(directory)) if (file.endsWith('.running.json') && isHostRunId(file.slice(0,-13))) {
86
+ const base=path.join(directory,file.slice(0,-13))
87
+ if (claimed.has(base)) continue
88
+ let prior: {pid?:number;started?:string;hostPid?:number;hostStarted?:string;containerId?:string} | undefined
89
+ try { prior=JSON.parse(await readFile(base+'.process.json','utf8')) } catch(error) { if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error }
90
+ if(prior?.containerId && prior.containerId!==containerId){
91
+ const touched=await stat(base+'.events').then(s=>s.mtimeMs,()=>0)
92
+ if(Date.now()-touched<15000)continue
93
+ }
94
+ if (!prior?.containerId && prior?.hostPid && prior.hostStarted && await processStart(prior.hostPid)===prior.hostStarted) continue
95
+ if (!prior?.containerId && prior?.pid && await processStart(prior.pid)) {
96
+ if (!prior.hostPid) throw Error('Previous host CLI is still running; stop it before recovery')
97
+ if (prior.started && await processStart(prior.pid)===prior.started) terminateJob({pid:prior.pid,exitCode:null,signalCode:null} as ChildProcess)
98
+ }
99
+ await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
100
+ await rm(base+'.process.json',{force:true});await rm(base+'.running.json',{force:true})
101
+ }
102
+ }
80
103
  try {
81
104
  for (const agent of installation.agents) {
82
105
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
@@ -89,7 +112,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
89
112
  if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
90
113
  additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
91
114
  }
92
- if (agent.toolsHome) {
115
+ if (agent.toolsHome && !containerId) {
93
116
  if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
94
117
  const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
95
118
  if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
@@ -99,6 +122,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
99
122
  const lock=path.join(directory,'worker.lock')
100
123
  try {
101
124
  const prior=JSON.parse(await readFile(lock,'utf8'))
125
+ if(prior.containerId&&prior.containerId!==containerId)throw Error('Previous isolated worker still owns the claim lock')
102
126
  const currentStart=await processStart(prior.pid)
103
127
  if (!prior.started || !currentStart || prior.started===currentStart) {
104
128
  try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
@@ -107,34 +131,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
107
131
  await rm(lock)
108
132
  }
109
133
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
110
- await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
134
+ await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid),...(containerId ? {containerId} : {})}),{mode:0o600,flag:'wx'})
111
135
  locks.push(lock)
112
136
  const models = await freshCatalog(agent)
113
137
  await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
114
138
  const modelsFile=path.join(directory,'models.json')
115
139
  await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
116
- // A host crash is terminal for a claimed job. Never replay an action.
117
- for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
118
- const base=path.join(directory,file.slice(0,-13))
119
- try {
120
- const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
121
- try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
122
- catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
123
- } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
124
- await rm(base+'.process.json',{force:true})
125
- await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
126
- await rm(path.join(directory,file))
127
- }
128
- if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
140
+ await recoverClaims(agent)
141
+ if (agent.toolsHome && !containerId) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
129
142
  }
130
143
  let catalogAt=Date.now()
131
144
  const catalogSeen=new Map<HostBinding,string>()
132
145
  while (!signal.aborted) {
133
146
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
134
- if(parent) { try { process.kill(parent,0) } catch { break } }
147
+ if(parent && !retiring) { try { process.kill(parent,0) } catch { break } }
148
+ if (retire?.aborted && !retiring) {
149
+ retiring=true
150
+ await catalogRefresh
151
+ for (const lock of locks.splice(0)) await rm(lock,{force:true})
152
+ }
153
+ if (retiring && !tasks.size) break
135
154
  // Keep transport liveness independent of native catalog I/O.
136
- for (const agent of installation.agents) await beat(agent)
137
- if(!catalogRefresh && Date.now()-catalogAt>30000){
155
+ if (!retiring) for (const agent of installation.agents) await beat(agent)
156
+ if(!retiring && !catalogRefresh && Date.now()-catalogAt>30000){
138
157
  catalogAt=Date.now()
139
158
  catalogRefresh=(async()=>{
140
159
  for(const agent of installation.agents){
@@ -149,6 +168,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
149
168
  }
150
169
  for (const agent of installation.agents) {
151
170
  const directory=path.join(agent.controlDir,'host-executor')
171
+ if (retiring) continue
172
+ await recoverClaims(agent)
152
173
  for (const file of await readdir(directory)) {
153
174
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
154
175
  const id=file.slice(0,-13)
@@ -168,9 +189,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
168
189
  let workspace = activeWorkspaces.get(agent)
169
190
  if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
170
191
  if (!workspace) {
171
- const release = agent.toolsHome
192
+ const release = agent.toolsHome && !containerId
172
193
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
173
- if (agent.toolsHome && !release) continue
194
+ if (agent.toolsHome && !containerId && !release) continue
174
195
  workspace = { lanes: new Map(), release }
175
196
  activeWorkspaces.set(agent, workspace)
176
197
  }
@@ -184,7 +205,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
184
205
  await workspace.release?.()
185
206
  }
186
207
  }
187
- try { await rename(base+'.request.json',base+'.running.json') }
208
+ try {
209
+ await writeFile(base+'.process.json',JSON.stringify({hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
210
+ await appendFile(base+'.events','',{mode:0o600})
211
+ await rename(base+'.request.json',base+'.running.json')
212
+ }
188
213
  catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
189
214
  const task=(async()=>{
190
215
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
@@ -205,7 +230,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
205
230
  // Script occurrences come from the authorized run record, never the request.
206
231
  if (run?.script) {
207
232
  if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
208
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
233
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
209
234
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
210
235
  script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
211
236
  job=await launch(request.texts,options)
@@ -237,14 +262,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
237
262
  // transient native-client probe failure must not reject a model
238
263
  // that the host just advertised to the relay and application.
239
264
  if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
240
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
265
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
241
266
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
242
267
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
243
268
  promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
244
269
  job=await launch(request.texts,options)
245
270
  }
246
271
  active.set(base,job.child)
247
- await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
272
+ // Observe completion before process metadata I/O: a fast CLI may
273
+ // already have exited when launch returns, or while that I/O waits.
274
+ const closed=job.child.exitCode!==null||job.child.signalCode!==null
275
+ ? Promise.resolve(job.child.exitCode??1)
276
+ : new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
248
277
  if(signal.aborted)terminateJob(job.child)
249
278
  // Scheduled Claude streams every event; forward only the startup summary.
250
279
  const startup = observeClaude ? startupObserver() : undefined
@@ -255,8 +284,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
255
284
  if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
256
285
  })
257
286
  job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
258
- cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
259
- const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
287
+ await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid,started:job.child.pid ? await processStart(job.child.pid) : undefined,hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
288
+ cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{}); const now=new Date();void utimes(base+'.events',now,now).catch(()=>{})},250)
289
+ const code=await closed
260
290
  if (cancellation) clearInterval(cancellation)
261
291
  await job.cleanup()
262
292
  job = undefined
@@ -290,8 +320,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
290
320
  }
291
321
 
292
322
  if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
293
- const abort=new AbortController()
323
+ const abort=new AbortController(),retire=new AbortController()
324
+ process.once('SIGUSR2',()=>retire.abort())
294
325
  for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
295
326
  const config=JSON.parse(await readFile(process.argv[2],'utf8'))
296
- await serveHostExecutor(config,abort.signal)
327
+ await serveHostExecutor(config,abort.signal,undefined,undefined,retire.signal)
297
328
  }
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
27
27
  import { loadConfig, type Config } from './config.js'
28
28
  import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
29
29
  import { ApprovalStore } from './approval.js'
30
- import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
30
+ import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
31
31
  import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
32
32
  import { assertScheduledModel } from './model-policy.js'
33
33
  import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
@@ -96,7 +96,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
96
96
  let activeBackend = false
97
97
  const ownerStopped = new WeakSet<ChildProcess>()
98
98
  // Host-transport executors outlive this relay; a graceful stop hands them off.
99
- const hostTransport = process.env.EZ_EXECUTOR_TRANSPORT === 'host' && !config.channelBackendUrl
99
+ const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
100
100
  const childRuns = new WeakMap<ChildProcess, string>()
101
101
  const handedOff = new WeakSet<ChildProcess>()
102
102
  let activeChild: ChildProcess | null = null
@@ -668,8 +668,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
668
668
  }
669
669
 
670
670
  let outboxWork: Promise<void> | undefined
671
- const drainOutbox = (onlyRunId?: string): Promise<void> => {
672
- if (shuttingDown) return outboxWork ?? Promise.resolve()
671
+ const drainOutbox = (onlyRunId?: string, stopping = false): Promise<void> => {
672
+ if (shuttingDown && !stopping) return outboxWork ?? Promise.resolve()
673
673
  return outboxWork ?? (outboxWork = (async () => {
674
674
  for (const item of await runs.pendingOutbox()) {
675
675
  if (onlyRunId && item.runId !== onlyRunId) continue
@@ -773,6 +773,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
773
773
  // children, host executor, CLIs) enqueue and query the memory ledger
774
774
  // through the shared handler in delivery-socket.ts. The pump below stays
775
775
  // the single delivery executor; the socket only admits work into it.
776
+ let drainingHandoff=false
776
777
  let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
777
778
  const handleDeliveryOp = createLedgerHandler(config.controlDir, {
778
779
  wake: () => { void drainOutbox() },
@@ -835,7 +836,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
835
836
  const owner = (await control.status()).owner
836
837
  if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
837
838
  // This intentionally uses the reader that does not create a schedules directory.
838
- const active = await scheduler.listActiveReadOnly(await runs.list())
839
+ const active = await scheduler.listTasksReadOnly(await runs.list())
839
840
  return { owner, active, text: scheduledTasksText(active, owner) }
840
841
  }
841
842
  const replyScheduledTasks = async (ctx: Context, number?: number) => {
@@ -892,6 +893,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
892
893
  : []),
893
894
  ].join('\n')
894
895
  }
896
+ const stopActiveWork = async (): Promise<boolean> => {
897
+ const children: [string | undefined, ChildProcess][] = [...background]
898
+ if (activeChild) children.unshift([undefined, activeChild])
899
+ let requested = true
900
+ for (const [id, child] of children) {
901
+ if (!await terminateJob(child)) { requested = false; continue }
902
+ ownerStopped.add(child)
903
+ if (id) await scheduler.cancel(id)
904
+ }
905
+ return requested
906
+ }
907
+
895
908
  const cancelPending = async () => {
896
909
  let count = 0
897
910
  await withStartLock(async () => {
@@ -1030,16 +1043,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1030
1043
  if (text === '/stop') {
1031
1044
  const running = await runs.running(false)
1032
1045
  if ((running && running.pid) || background.size) {
1033
- try {
1034
- if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
1035
- for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
1036
- } catch {}
1037
- if (activeTypingTimer) {
1046
+ let requested = false
1047
+ try { requested = await stopActiveWork() } catch {}
1048
+ if (requested && activeTypingTimer) {
1038
1049
  clearInterval(activeTypingTimer)
1039
1050
  activeTypingTimer = null
1040
1051
  }
1041
1052
  await ctx.reply(
1042
- '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
1053
+ requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
1054
+ : 'A stop request failed; some work remains active.',
1043
1055
  )
1044
1056
  } else {
1045
1057
  await ctx.reply('No active run in progress.')
@@ -1262,11 +1274,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1262
1274
  } else if (action === 'stop') {
1263
1275
  const running = await runs.running(false)
1264
1276
  if ((running && running.pid) || background.size) {
1265
- if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
1266
- for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
1267
- await ctx.answerCallbackQuery({ text: 'Run stopped' })
1277
+ let requested = false
1278
+ try { requested = await stopActiveWork() } catch {}
1279
+ await ctx.answerCallbackQuery({ text: requested ? 'Stop requested' : 'Stop request failed' })
1268
1280
  await ctx.reply(
1269
- '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
1281
+ requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
1282
+ : 'A stop request failed; some work remains active.',
1270
1283
  )
1271
1284
  } else {
1272
1285
  await ctx.answerCallbackQuery({ text: 'No run active' })
@@ -1288,7 +1301,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1288
1301
  // unknown outcome is recorded interrupted, which holds its schedule.
1289
1302
  const adoptHandoff = async (): Promise<void> => {
1290
1303
  const handed = await takeRunHandoff(config.controlDir)
1291
- if (!hostTransport) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
1304
+ if (!await hostTransport()) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
1292
1305
  // Admit every record before the first attach, so no trigger races a hold.
1293
1306
  const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
1294
1307
  for (const run of admitted) await withStartLock(async () => {
@@ -1353,21 +1366,25 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1353
1366
  shuttingDown = true
1354
1367
  runtimeStarted = false
1355
1368
  pollingAbort.abort()
1369
+ let handedRecords:RunRecord[]=[]
1356
1370
  // Hand off running host work before any wait can outlast the stop grace
1357
1371
  // period. The lock lets an in-flight launch register; later ones see shutdown.
1358
- if (hostTransport) await withStartLock(async () => {
1372
+ if (await hostTransport()) await withStartLock(async () => {
1359
1373
  const handoff = new Map<ChildProcess, RunRecord>()
1360
1374
  for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) {
1361
1375
  const run = childRuns.has(child) ? await runs.get(childRuns.get(child)!) : null
1362
1376
  if (run?.status === 'running' && !ownerStopped.has(child) && child.exitCode === null && child.signalCode === null) handoff.set(child, run)
1363
1377
  }
1364
1378
  if (!handoff.size) return
1365
- try { await saveRunHandoff(config.controlDir, [...handoff.values()]) }
1379
+ try { handedRecords=[...handoff.values()];await saveRunHandoff(config.controlDir, handedRecords) }
1366
1380
  catch (error) { console.error('Run handoff failed; stopping host work', safeError(error)); return }
1367
1381
  // SIGKILL leaves no cancel marker, so the host keeps each executor running.
1368
1382
  for (const child of handoff.keys()) { handedOff.add(child); child.kill('SIGKILL') }
1369
1383
  })
1370
- await deliveryServer?.stop().catch(() => {})
1384
+ drainingHandoff=handedRecords.length>0
1385
+ if(deliveryServer)await deliveryServer.stop()
1386
+ else if(drainingHandoff){await outboxWork?.catch(() => {});await drainOutbox(undefined,true)}
1387
+ if(handedRecords.length)await saveRunHandoff(config.controlDir,handedRecords)
1371
1388
  deliveryServer = null
1372
1389
  await applicationChannel.stop()
1373
1390
  wakePollRetry?.()
@@ -1402,6 +1419,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1402
1419
  throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
1403
1420
  const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
1404
1421
  deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
1422
+ beforeClose:async()=>{if(drainingHandoff){await outboxWork?.catch(()=>{});await drainOutbox(undefined,true)}},
1405
1423
  ...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
1406
1424
  })
1407
1425
  // Reconcile, then adopt handed-off runs, before any intake can launch work.
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
23
23
  export async function workspaceLease(home,owner={kind:'plugin'}) {
24
24
  const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
25
25
  await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
26
- try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
26
+ try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');if(owner.kind==='native'&&prior.owner.kind==='native')return async()=>{};return undefined;}
27
27
  finally {await fs.rm(temp);}
28
28
  return async()=>{await fs.rm(file);};
29
29
  }
package/src/runs.ts CHANGED
@@ -162,7 +162,9 @@ const handedRun = (value: unknown): value is RunRecord => {
162
162
 
163
163
  export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
164
164
  const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
165
- await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records }), { mode: 0o600 })
165
+ const ids=new Set(records.map(run=>run.id))
166
+ const receipts=[...outboxFor(controlDir).values()].filter(item=>item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))
167
+ await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records, receipts }), { mode: 0o600 })
166
168
  await rename(temporary, file)
167
169
  }
168
170
 
@@ -176,12 +178,16 @@ export const takeRunHandoff = async (controlDir: string, now = Date.now()): Prom
176
178
  try { raw = await readFile(handoffFile(controlDir), 'utf8') }
177
179
  catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
178
180
  await rm(handoffFile(controlDir))
179
- const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown }
181
+ const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown; receipts?: unknown }
180
182
  const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
181
183
  if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
182
184
  if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
183
185
  const valid = value.runs.filter(handedRun)
184
186
  if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
187
+ const ids=new Set(valid.map(run=>run.id))
188
+ if(Array.isArray(value.receipts)&&value.receipts.length<=MAX_TERMINAL_OUTBOX){
189
+ for(const item of value.receipts as StoredOutboxItem[])if(item&&typeof item.id==='string'&&/^[a-zA-Z0-9_-]+$/.test(item.id)&&item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))outboxFor(controlDir).set(item.id,item)
190
+ }
185
191
  return [...new Map(valid.map(run => [run.id, run])).values()]
186
192
  } catch (error) {
187
193
  console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
@@ -83,7 +83,8 @@ The run records each attempt, setup, failure category and any reported reset tim
83
83
  Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
84
84
  Creates a scheduled task. Instructions are text, never shell commands.
85
85
  Use --now to run once. Run completion is not delivery proof.
86
- Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
86
+ Edit replaces the full schedule but keeps its paused/enabled state; only pause/resume toggle it.
87
+ Pause/remove affect future work; cancel stops a particular run.
87
88
  Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
88
89
  const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
89
90
  const owner=(await control.status()).owner
@@ -195,13 +196,15 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
195
196
  v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
196
197
  if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
197
198
  const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
199
+ // Edit keeps the paused/enabled state; only pause/resume toggle it.
200
+ const enabled = previousSchedule?.enabled ?? true
198
201
  const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
199
202
  const origin = caller?.application ?? caller?.delivery
200
203
  const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
201
204
  if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
202
205
  if (v.script) {
203
206
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
204
- originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
207
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
205
208
  script:{id:v.script,args:v.arg ?? []}},action==='create'))
206
209
  console.log(JSON.stringify(result,null,2));return
207
210
  }
@@ -216,7 +219,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
216
219
  if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
217
220
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
218
221
  preflight,
219
- originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
222
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
220
223
  execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
221
224
  }else{
222
225
  if(!id)throw new Error('ID required')
@@ -2,7 +2,7 @@ import { executionDefaults } from './model-policy.js'
2
2
  import { presetLabel, type AiPreset } from './ai.js'
3
3
  import { AUTH_PROFILE_CLIS } from './auth-profile.js'
4
4
  import type { Owner } from './control-state.js'
5
- import type { Schedule, ActiveSchedule } from './scheduler.js'
5
+ import type { Schedule, ScheduledTaskView } from './scheduler.js'
6
6
 
7
7
  const ownsSchedule = (owner: Owner, schedule: Schedule) =>
8
8
  schedule.owner.telegramUserId === owner.telegramUserId &&
@@ -24,7 +24,7 @@ const setupLabel = (preset: AiPreset) => {
24
24
  }
25
25
 
26
26
  // Script schedules have no AI preset; show what core actually invokes.
27
- const engineLabel = (schedule: ActiveSchedule) => schedule.script
27
+ const engineLabel = (schedule: ScheduledTaskView) => schedule.script
28
28
  ? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
29
29
  : [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
30
30
 
@@ -67,28 +67,30 @@ const preview = (schedule: Schedule) => {
67
67
  return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
68
68
  }
69
69
 
70
- const currentState = (schedule: ActiveSchedule) =>
71
- schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : '● Active'
70
+ const currentState = (schedule: ScheduledTaskView) =>
71
+ [schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : undefined,
72
+ !schedule.enabled ? '⏸ Paused' : undefined].filter(Boolean).join(' · ') || '● Active'
72
73
 
73
- const lastRun = (schedule: ActiveSchedule) => {
74
+ const lastRun = (schedule: ScheduledTaskView) => {
74
75
  if (!schedule.lastRun) return '— Not run yet'
75
76
  const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
76
77
  return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
77
78
  }
78
79
 
79
- export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =>
80
+ export const ownedScheduledTasks = (schedules: ScheduledTaskView[], owner: Owner) =>
80
81
  schedules.filter((schedule) => ownsSchedule(owner, schedule))
81
82
  .sort((a, b) => a.name.localeCompare(b.name))
82
83
 
83
- export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
84
+ export const scheduledTasksText = (schedules: ScheduledTaskView[], owner: Owner) => {
84
85
  const owned = ownedScheduledTasks(schedules, owner)
85
- if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
86
- return [`📅 Scheduled tasks · ${owned.length} active`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
86
+ if (!owned.length) return '📅 Scheduled tasks\n\nNo scheduled tasks.'
87
+ const paused = owned.filter(schedule => !schedule.enabled).length
88
+ return [`📅 Scheduled tasks · ${owned.length - paused} active · ${paused} paused`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
87
89
  `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Schedule · ${frequency(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n Execution · ${engineLabel(schedule)}\n ${preview(schedule)}`
88
90
  )].join('\n\n')
89
91
  }
90
92
 
91
- export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
93
+ export const scheduledTaskDetailText = (schedule: ScheduledTaskView, number: number, total?: number) => {
92
94
  const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
93
95
  const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
94
96
  return [
package/src/scheduler.ts CHANGED
@@ -29,7 +29,7 @@ export type ScheduledRunSummary = {
29
29
  at: string
30
30
  currentRevision: boolean
31
31
  }
32
- export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
32
+ export type ScheduledTaskView = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
33
33
  export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
34
34
  export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
35
35
  const s = v as ScheduledOrigin
@@ -102,10 +102,9 @@ export class Scheduler {
102
102
  return nextOccurrence(s.trigger,-1)
103
103
  }
104
104
  }
105
- async listActiveReadOnly(runs: RunRecord[]): Promise<ActiveSchedule[]> {
106
- const active: ActiveSchedule[] = []
105
+ async listTasksReadOnly(runs: RunRecord[]): Promise<ScheduledTaskView[]> {
106
+ const active: ScheduledTaskView[] = []
107
107
  for (const s of await this.listReadOnly()) {
108
- if (!s.enabled) continue
109
108
  const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
110
109
  const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
111
110
  .filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
@@ -113,11 +112,11 @@ export class Scheduler {
113
112
  .sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
114
113
  const last = history[0]
115
114
  const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
116
- if (!runState && current.some(r => holdsSchedule(s,r))) continue
115
+ if (s.enabled && !runState && current.some(r => holdsSchedule(s,r))) continue
117
116
  try {
118
- const nextAt = await this.pendingOccurrence(s)
117
+ const nextAt = s.enabled ? await this.pendingOccurrence(s) : null
119
118
  const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
120
- if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
119
+ if (!s.enabled || runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
121
120
  lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
122
121
  } : {})})
123
122
  }