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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -33,8 +33,8 @@ export function execute(command,args,options={}) {
33
33
  export async function textAtomic(file,text) {
34
34
  const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
35
35
  }
36
- export async function backupStateDirectory(source,destination) {
37
- await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
36
+ export async function backupStateDirectory(source,destination,omit=[]) {
37
+ await fs.cp(source,destination,{recursive:true,filter:async entry=>!omit.includes(path.relative(source,entry).split(path.sep)[0])&&!(await fs.lstat(entry)).isSocket()});
38
38
  }
39
39
  export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
40
40
  export async function packageManager(root,run=execute) {
@@ -159,7 +159,7 @@ export async function perform(home,job,hooks) {
159
159
  const root=path.join(dir,'runtime');await fs.rm(root,{recursive:true,force:true});await extract(archive,root);
160
160
  const next=await eligibility(home,job.target,root,job.automatic);
161
161
  if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
162
- job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
162
+ job.status='applying';job.preserveRuns=hooks.preserveRuns===true;job.root=root;job.startedAt=new Date().toISOString();await save();
163
163
  const complete=async()=>{
164
164
  job.status='completed';job.endedAt=new Date().toISOString();await save();
165
165
  try { job.cleanup=await cleanupStaleBackups(home);await save(); }
@@ -193,17 +193,30 @@ export async function perform(home,job,hooks) {
193
193
  });
194
194
  const image=`ez-upgrade-${digest(JSON.stringify([job.sha256,buildArgs])).slice(0,24)}`;
195
195
  await run('docker',['build','--target','runtime',...buildArgs,'-t',image,root]);
196
+ const backup=path.join(dir,'backup');
197
+ const backupState=async()=>{
198
+ await fs.mkdir(backup,{mode:0o700});
199
+ await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
200
+ await backupStateDirectory(agent.controlDir,path.join(backup,'control'),hooks.preserveRuns ? ['host-executor'] : []);
201
+ for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
202
+ };
203
+ // Native client state can be many gigabytes. This advisory copy never
204
+ // rewinds live state, so compatible generations keep serving throughout it.
205
+ if(hooks.preserveRuns)await backupState();
196
206
  const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
197
207
  const cid=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
198
208
  if(!cid||/\s/.test(cid))throw Error('Expected one running relay');
199
209
  const oldImage=(await run('docker',['inspect','--format','{{.Image}}',cid])).trim();
200
210
  if(!/^sha256:[a-f0-9]{64}$/.test(oldImage))throw Error('Cannot pin rollback image');
201
211
  job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
202
- await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
203
- const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
204
- await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
205
- await backupStateDirectory(agent.controlDir,path.join(backup,'control'));
206
- for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
212
+ const activate=async()=>{
213
+ const current=await eligibility(home,job.target,root,job.automatic);
214
+ if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
215
+ const pause=path.join(agent.controlDir,'upgrade-pause.json');
216
+ if(hooks.preserveRuns)await atomic(pause,{id:job.id});
217
+ try {
218
+ await run('docker',[...relayArgs(config),'stop','relay']);await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
219
+ if(!hooks.preserveRuns)await backupState();
207
220
  let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
208
221
  await textAtomic(envFile,env);
209
222
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
@@ -213,6 +226,9 @@ export async function perform(home,job,hooks) {
213
226
  const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
214
227
  job.brokerReadiness=await refreshBroker(config,run,brokerImage);
215
228
  }
229
+ }finally{if(hooks.preserveRuns)await fs.rm(pause,{force:true});}
230
+ };
231
+ if(hooks.preserveRuns)await locked(home,activate,{drainInvocations:true,signal:hooks.signal});else await activate();
216
232
  } else {
217
233
  const old=next.old.record;
218
234
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
@@ -266,7 +282,7 @@ export async function recover(home,job,hooks) {
266
282
  return /^sha256:[a-f0-9]{64}$/.test(pinned||'')&&current.Image===pinned&&current.State?.Running===true&&current.State?.Health?.Status==='healthy';
267
283
  })().catch(()=>false);
268
284
  if(!retained)await run('docker',[...relayArgs(config),'stop','relay']);
269
- await hooks.stopHost();
285
+ await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
270
286
  await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
271
287
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
272
288
  await hooks.startHost(job.rollback.packageRoot);
@@ -5,7 +5,7 @@ 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
7
  import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
8
- import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
8
+ import { perform, environment, brokerNeedsRefresh, execute, relayArgs } 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']);
11
11
  const providerEnvironmentKey=value=>{
@@ -54,7 +54,7 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
54
54
  }
55
55
  // Plugin replacement drains command leases at activation, not native turns.
56
56
  export async function withUpgrade(control,job,signal,apply,options) {
57
- if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
57
+ if((job.target==='main'&&!options?.preserveRuns)||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
58
58
  if(!signal.aborted)return apply();
59
59
  }
60
60
  export async function queueAutomatic(home,available) {
@@ -84,20 +84,88 @@ export async function supervise(deployment,signal,{discover=check}={}) {
84
84
  if(prior&&(!Number.isSafeInteger(prior.pid)||prior.pid<1))throw Error('Corrupt supervisor lock');
85
85
  if(prior){try{process.kill(prior.pid,0);throw Error('Supervisor already running');}catch(e){if(e.code!=='ESRCH')throw e;}await fs.rm(lock);}
86
86
  await fs.writeFile(lock,JSON.stringify({pid:process.pid}),{flag:'wx',mode:0o600});
87
- let child;
87
+ let child,container;
88
+ let retirements=[];
89
+ let reloadPending=false;
90
+ const generations=new Map();
91
+ const removeContainer=async id=>{
92
+ const generation=generations.get(id);
93
+ if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});await fs.rm(generation.configFile,{force:true});generations.delete(id);}
94
+ else await execute('docker',['rm',id]);
95
+ };
88
96
  const stopHost=async()=>{
97
+ if(container){await execute('docker',['stop','--time','10',container]);await removeContainer(container);container=undefined;}
89
98
  if(!child)return;const running=child;child=undefined;
90
99
  if(running.exitCode!==null)return;
91
100
  const closed=new Promise(resolve=>running.once('close',resolve));running.kill('SIGTERM');
92
101
  const timer=setTimeout(()=>running.kill('SIGKILL'),10000);await closed;clearTimeout(timer);
93
102
  };
94
103
  const startHost=async root=>{
95
- if(host.isolation==='isolated')return
104
+ if(host.isolation==='isolated') {
105
+ const pkg=await read(path.join(root,'package.json'));
106
+ if(pkg.ezRelease?.containerExecutor!==1)return;
107
+ const lock=path.join(agent.controlDir,'host-executor/worker.lock');
108
+ const prior=await read(lock).catch(missing);
109
+ if(prior?.containerId){
110
+ if(!/^[a-f0-9]{12,64}$/.test(prior.containerId))throw Error('Invalid previous isolated worker identity');
111
+ const running=await execute('docker',['inspect','--format','{{.State.Running}}',prior.containerId],{stdoutOnly:true}).catch(()=>null);
112
+ if(running?.trim()==='true')throw Error('Previous isolated worker is still running');
113
+ // Only the supervisor can prove a foreign container stopped.
114
+ if(JSON.stringify(await read(lock))!==JSON.stringify(prior))throw Error('Worker lock changed during recovery');
115
+ await fs.rm(lock);
116
+ }
117
+ const config=(await state(home)).config;
118
+ const deployment=JSON.parse(await execute('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
119
+ const relay=deployment.services.relay;
120
+ if(!relay?.image||!Array.isArray(relay.volumes))throw Error('Invalid isolated relay binding');
121
+ const generation=path.basename(root).replace(/[^a-zA-Z0-9_.-]/g,'-')+'-'+Date.now();
122
+ const project='ez-executor-'+generation.toLowerCase();
123
+ const file=path.join(directory,project+'.json');
124
+ const configFile=path.join(directory,project+'-binding.json');
125
+ await atomic(configFile,{...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))});
126
+ const worker={image:relay.image,init:true,restart:'no',user:`${relay.environment.EZ_RUNTIME_UID||1000}:${relay.environment.EZ_RUNTIME_GID||1000}`,
127
+ cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','--import','/app/node_modules/tsx/dist/loader.mjs','/app/src/host-executor.ts','/run/executor.json'],
128
+ environment:{...relay.environment,EZ_CONTAINER_EXECUTOR:'1',EZ_EXECUTOR_TRANSPORT:'local'},
129
+ volumes:[...relay.volumes,{type:'bind',source:configFile,target:'/run/executor.json',read_only:true}],tmpfs:['/tmp'],
130
+ ...Object.fromEntries(['networks','network_mode','extra_hosts','dns','dns_search'].filter(key=>relay[key]!==undefined).map(key=>[key,relay[key]]))};
131
+ if(worker.networks&&!Array.isArray(worker.networks))worker.networks=Object.fromEntries(Object.keys(worker.networks).map(name=>[name,{}]));
132
+ const networks=worker.networks?Object.fromEntries(Object.entries(deployment.networks||{}).map(([name,binding])=>[name,{name:binding.name,external:true}])):undefined;
133
+ await atomic(file,{services:{executor:worker},...(networks?{networks}:{})});
134
+ await execute('docker',['compose','--project-name',project,'--file',file,'up','-d','--no-build','executor']);
135
+ container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-q','executor'],{stdoutOnly:true})).trim();
136
+ if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
137
+ generations.set(container,{project,file,configFile});
138
+ for(let n=0;n<300;n++){
139
+ const running=await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true});
140
+ if(running.trim()!=='true')throw Error('Isolated executor exited during startup');
141
+ const heartbeat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
142
+ if(heartbeat?.containerExecutor===true&&container.startsWith(heartbeat.containerId||'missing')&&Date.now()-heartbeat.at<1000)return;
143
+ await sleep(100);
144
+ }
145
+ throw Error('Isolated executor heartbeat timeout');
146
+ }
96
147
  child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
97
- {env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
148
+ {detached:true,env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
98
149
  let error;child.once('error',e=>{error=e;});
99
150
  await waitForHostHeartbeat(agent.controlDir,child,()=>error);
100
151
  };
152
+ const retireHost=async()=>{
153
+ if(container){await execute('docker',['kill','--signal','SIGUSR2',container]);retirements.push({container});container=undefined;}
154
+ if(child){const running=child;child=undefined;if(running.exitCode===null){running.kill('SIGUSR2');retirements.push({child:running});}}
155
+ // A retiring generation releases its single claim lock before replacement.
156
+ for(let n=0;n<300;n++){
157
+ if(!await fs.stat(path.join(agent.controlDir,'host-executor/worker.lock')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e;}))return;
158
+ await sleep(100);
159
+ }
160
+ throw Error('Native executor retirement timeout');
161
+ };
162
+ const preserveRuns=async job=>{
163
+ if(job.target!=='main')return false;
164
+ const old=await read(path.join((await state(home)).config.packageRoot,'package.json'));
165
+ const candidate=await read(path.join(jobPath(home,job.id),'package/package.json'));
166
+ return old.ezRelease?.runHandoff===1&&candidate.ezRelease?.runHandoff===1&&
167
+ (host.isolation!=='isolated'||(container&&old.ezRelease?.containerExecutor===1&&candidate.ezRelease?.containerExecutor===1));
168
+ };
101
169
  const pause=path.join(agent.controlDir,'upgrade-pause.json');
102
170
  const refreshRequirement=async job=>{
103
171
  if(job.target==='main')return false;
@@ -119,36 +187,56 @@ export async function supervise(deployment,signal,{discover=check}={}) {
119
187
  if(owner&&prior&&owner.pid===prior.pid)await fs.rm(registryLock);
120
188
  // Wait for an orphaned transport's parent-watch to terminate it first.
121
189
  await sleep(1200);
190
+ const workerOwner=await read(path.join(agent.controlDir,'host-executor/worker.lock')).catch(missing);
191
+ if(host.isolation==='isolated'&&workerOwner?.containerId){
192
+ if(!/^[a-f0-9]{12,64}$/.test(workerOwner.containerId))throw Error('Invalid isolated worker identity');
193
+ if((await execute('docker',['inspect','--format','{{.State.Running}}',workerOwner.containerId],{stdoutOnly:true}).catch(()=>''))?.trim()==='true')container=workerOwner.containerId;
194
+ }
122
195
  const interrupted=(await jobs(home)).find(j=>j.status==='applying');
123
196
  if(interrupted){
124
197
  const refreshPluginBroker=await refreshRequirement(interrupted);
125
198
  const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
126
- ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
127
- : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
128
- if(result?.status==='completed'&&interrupted.target==='main')return;
199
+ ? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost,retireHost,preserveRuns:interrupted.preserveRuns===true}));}finally{await fs.rm(pause,{force:true});}})()
200
+ : await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,retireHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
201
+ if(['completed','rolled-back'].includes(result?.status)&&interrupted.target==='main')reloadPending=true;
129
202
  }
130
203
  const isolated=host.isolation==='isolated'
131
- if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
204
+ if(!isolated&&!child)await startHost((await state(home)).config.packageRoot);
205
+ const initializeIsolated=async()=>{
206
+ if(!isolated||container)return;
207
+ const root=(await state(home)).config.packageRoot;
208
+ if((await read(path.join(root,'package.json'))).ezRelease?.containerExecutor!==1)return;
209
+ await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root));
210
+ };
211
+ await initializeIsolated();
132
212
  let nextCheck=0;
133
213
  while(!signal.aborted) {
134
214
  if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
135
- const pending=(await jobs(home)).find(j=>j.status==='queued');
215
+ await initializeIsolated();
216
+ if(container&&(await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true})).trim()!=='true')throw Error('Isolated executor exited; supervisor service should restart');
217
+ if(reloadPending&&await idle(agent.controlDir))return;
218
+ // Reap retired containers only after their worker exits.
219
+ for(const item of retirements.filter(r=>r.container)){
220
+ if((await execute('docker',['inspect','--format','{{.State.Running}}',item.container],{stdoutOnly:true})).trim()==='false'){await removeContainer(item.container);retirements=retirements.filter(r=>r!==item);}
221
+ }
222
+ const pending=!reloadPending&&(await jobs(home)).find(j=>j.status==='queued');
136
223
  if(pending) {
137
224
  const refreshPluginBroker=await refreshRequirement(pending);
138
225
  if(refreshPluginBroker===null)continue;
226
+ const preserve=await preserveRuns(pending);
139
227
  const apply=async()=>{
140
228
  try {
141
- const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
142
- return pending.target==='main'?await locked(home,activate,{drainInvocations:true,signal}):await activate();
229
+ const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,retireHost,signal,refreshPluginBroker,preserveRuns:preserve});};
230
+ return pending.target==='main'&&!preserve?await locked(home,activate,{drainInvocations:true,signal}):await activate();
143
231
  } catch(error) {
144
232
  // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
145
233
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
146
234
  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;
147
235
  }
148
236
  };
149
- const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
150
- if(result?.status==='completed') {
151
- if(pending.target==='main')return;
237
+ const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker,preserveRuns:preserve});
238
+ if(['completed','rolled-back'].includes(result?.status)) {
239
+ if(pending.target==='main')reloadPending=true;
152
240
  nextCheck=0;
153
241
  }
154
242
  }
@@ -166,7 +254,8 @@ export async function supervise(deployment,signal,{discover=check}={}) {
166
254
  await sleep(500);
167
255
  }
168
256
  }finally {
169
- clearInterval(beat);await stopHost();await fs.rm(lock,{force:true});
257
+ clearInterval(beat);await stopHost();
258
+ for(const item of retirements){if(item.container){await execute('docker',['stop','--time','10',item.container]);await removeContainer(item.container);}else if(item.child.exitCode===null)item.child.kill('SIGTERM');}await fs.rm(lock,{force:true});
170
259
  if(!(await jobs(home)).some(j=>j.status==='applying'))await fs.rm(pause,{force:true});
171
260
  }
172
261
  }