@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/CHANGELOG.md +11 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +7 -2
- package/package.json +4 -2
- package/src/ai-cli.ts +4 -2
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +54 -10
- package/src/host-executor-client.ts +4 -1
- package/src/host-executor.ts +63 -32
- package/src/index.ts +37 -19
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +8 -2
- package/src/schedule-cli.ts +6 -3
- package/src/scheduled-tasks.ts +12 -10
- package/src/scheduler.ts +6 -7
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +24 -2
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +8 -1
- package/test/plugin-manager.test.mjs +1 -1
- package/test/run-handoff.test.ts +4 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +23 -15
- package/test/updates.test.mjs +36 -2
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 =
|
|
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.
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
await ctx.answerCallbackQuery({ text: '
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
package/src/schedule-cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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')
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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:
|
|
71
|
-
schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' :
|
|
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:
|
|
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:
|
|
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:
|
|
84
|
+
export const scheduledTasksText = (schedules: ScheduledTaskView[], owner: Owner) => {
|
|
84
85
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
85
|
-
if (!owned.length) return '📅 Scheduled tasks\n\nNo
|
|
86
|
-
|
|
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:
|
|
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
|
|
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
|
|
106
|
-
const active:
|
|
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
|
}
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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||'')&¤t.Image===pinned&¤t.State?.Running===true&¤t.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')
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
151
|
-
if(pending.target==='main')
|
|
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();
|
|
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
|
}
|