@jc_stack/ez-agents 0.1.0-beta.49 → 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/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- 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 +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- 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 +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
package/src/host-executor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Tasks } from './tasks.js'
|
|
|
3
3
|
import { ControlStore } from './control-state.js'
|
|
4
4
|
import { authorizeRun, readRun } from './delivery-socket.js'
|
|
5
5
|
import { runPromptSuffix } from './prompt-suffix.js'
|
|
6
|
-
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
|
|
6
|
+
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath, utimes, stat } from 'node:fs/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -42,13 +42,16 @@ export const refreshAgentCatalog = async (agent: HostBinding,
|
|
|
42
42
|
const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
|
|
43
43
|
|
|
44
44
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
|
|
45
|
-
readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]
|
|
45
|
+
readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>, retire?: AbortSignal) => {
|
|
46
46
|
if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
|
|
47
|
-
if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
|
|
47
|
+
if (installation.isolation === 'isolated' && process.env.EZ_CONTAINER_EXECUTOR !== '1') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
|
|
48
48
|
resolveExecutor(installation.cli)
|
|
49
49
|
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
50
50
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
51
51
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
52
|
+
const containerId = process.env.EZ_CONTAINER_EXECUTOR === '1' ? process.env.HOSTNAME : undefined
|
|
53
|
+
const hostStarted = await processStart(process.pid)
|
|
54
|
+
let retiring = false
|
|
52
55
|
const active = new Map<string, ChildProcess>()
|
|
53
56
|
// A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
|
|
54
57
|
const claimed = new Set<string>()
|
|
@@ -74,9 +77,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
74
77
|
const freshCatalog = readCatalog ?? catalog
|
|
75
78
|
const beat = async (agent: HostBinding) => {
|
|
76
79
|
const directory = path.join(agent.controlDir, 'host-executor')
|
|
77
|
-
await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
|
|
80
|
+
await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, ...(process.env.EZ_CONTAINER_EXECUTOR === '1' ? {containerExecutor:true,containerId} : {}), plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
|
|
78
81
|
await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
|
|
79
82
|
}
|
|
83
|
+
const recoverClaims = async (agent: HostBinding) => {
|
|
84
|
+
const directory=path.join(agent.controlDir,'host-executor')
|
|
85
|
+
for (const file of await readdir(directory)) if (file.endsWith('.running.json') && isHostRunId(file.slice(0,-13))) {
|
|
86
|
+
const base=path.join(directory,file.slice(0,-13))
|
|
87
|
+
if (claimed.has(base)) continue
|
|
88
|
+
let prior: {pid?:number;started?:string;hostPid?:number;hostStarted?:string;containerId?:string} | undefined
|
|
89
|
+
try { prior=JSON.parse(await readFile(base+'.process.json','utf8')) } catch(error) { if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error }
|
|
90
|
+
if(prior?.containerId && prior.containerId!==containerId){
|
|
91
|
+
const touched=await stat(base+'.events').then(s=>s.mtimeMs,()=>0)
|
|
92
|
+
if(Date.now()-touched<15000)continue
|
|
93
|
+
}
|
|
94
|
+
if (!prior?.containerId && prior?.hostPid && prior.hostStarted && await processStart(prior.hostPid)===prior.hostStarted) continue
|
|
95
|
+
if (!prior?.containerId && prior?.pid && await processStart(prior.pid)) {
|
|
96
|
+
if (!prior.hostPid) throw Error('Previous host CLI is still running; stop it before recovery')
|
|
97
|
+
if (prior.started && await processStart(prior.pid)===prior.started) terminateJob({pid:prior.pid,exitCode:null,signalCode:null} as ChildProcess)
|
|
98
|
+
}
|
|
99
|
+
await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
100
|
+
await rm(base+'.process.json',{force:true});await rm(base+'.running.json',{force:true})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
80
103
|
try {
|
|
81
104
|
for (const agent of installation.agents) {
|
|
82
105
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
@@ -89,7 +112,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
89
112
|
if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
|
|
90
113
|
additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
|
|
91
114
|
}
|
|
92
|
-
if (agent.toolsHome) {
|
|
115
|
+
if (agent.toolsHome && !containerId) {
|
|
93
116
|
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
94
117
|
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
95
118
|
if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
|
|
@@ -99,6 +122,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
99
122
|
const lock=path.join(directory,'worker.lock')
|
|
100
123
|
try {
|
|
101
124
|
const prior=JSON.parse(await readFile(lock,'utf8'))
|
|
125
|
+
if(prior.containerId&&prior.containerId!==containerId)throw Error('Previous isolated worker still owns the claim lock')
|
|
102
126
|
const currentStart=await processStart(prior.pid)
|
|
103
127
|
if (!prior.started || !currentStart || prior.started===currentStart) {
|
|
104
128
|
try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
|
|
@@ -107,34 +131,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
107
131
|
await rm(lock)
|
|
108
132
|
}
|
|
109
133
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
110
|
-
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
134
|
+
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid),...(containerId ? {containerId} : {})}),{mode:0o600,flag:'wx'})
|
|
111
135
|
locks.push(lock)
|
|
112
136
|
const models = await freshCatalog(agent)
|
|
113
137
|
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
|
|
114
138
|
const modelsFile=path.join(directory,'models.json')
|
|
115
139
|
await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const base=path.join(directory,file.slice(0,-13))
|
|
119
|
-
try {
|
|
120
|
-
const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
|
|
121
|
-
try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
|
|
122
|
-
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
123
|
-
} catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
124
|
-
await rm(base+'.process.json',{force:true})
|
|
125
|
-
await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
126
|
-
await rm(path.join(directory,file))
|
|
127
|
-
}
|
|
128
|
-
if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
|
|
140
|
+
await recoverClaims(agent)
|
|
141
|
+
if (agent.toolsHome && !containerId) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
|
|
129
142
|
}
|
|
130
143
|
let catalogAt=Date.now()
|
|
131
144
|
const catalogSeen=new Map<HostBinding,string>()
|
|
132
145
|
while (!signal.aborted) {
|
|
133
146
|
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
134
|
-
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
147
|
+
if(parent && !retiring) { try { process.kill(parent,0) } catch { break } }
|
|
148
|
+
if (retire?.aborted && !retiring) {
|
|
149
|
+
retiring=true
|
|
150
|
+
await catalogRefresh
|
|
151
|
+
for (const lock of locks.splice(0)) await rm(lock,{force:true})
|
|
152
|
+
}
|
|
153
|
+
if (retiring && !tasks.size) break
|
|
135
154
|
// Keep transport liveness independent of native catalog I/O.
|
|
136
|
-
for (const agent of installation.agents) await beat(agent)
|
|
137
|
-
if(!catalogRefresh && Date.now()-catalogAt>30000){
|
|
155
|
+
if (!retiring) for (const agent of installation.agents) await beat(agent)
|
|
156
|
+
if(!retiring && !catalogRefresh && Date.now()-catalogAt>30000){
|
|
138
157
|
catalogAt=Date.now()
|
|
139
158
|
catalogRefresh=(async()=>{
|
|
140
159
|
for(const agent of installation.agents){
|
|
@@ -149,6 +168,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
149
168
|
}
|
|
150
169
|
for (const agent of installation.agents) {
|
|
151
170
|
const directory=path.join(agent.controlDir,'host-executor')
|
|
171
|
+
if (retiring) continue
|
|
172
|
+
await recoverClaims(agent)
|
|
152
173
|
for (const file of await readdir(directory)) {
|
|
153
174
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
154
175
|
const id=file.slice(0,-13)
|
|
@@ -168,9 +189,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
168
189
|
let workspace = activeWorkspaces.get(agent)
|
|
169
190
|
if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
|
|
170
191
|
if (!workspace) {
|
|
171
|
-
const release = agent.toolsHome
|
|
192
|
+
const release = agent.toolsHome && !containerId
|
|
172
193
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
173
|
-
if (agent.toolsHome && !release) continue
|
|
194
|
+
if (agent.toolsHome && !containerId && !release) continue
|
|
174
195
|
workspace = { lanes: new Map(), release }
|
|
175
196
|
activeWorkspaces.set(agent, workspace)
|
|
176
197
|
}
|
|
@@ -184,7 +205,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
184
205
|
await workspace.release?.()
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
|
-
try {
|
|
208
|
+
try {
|
|
209
|
+
await writeFile(base+'.process.json',JSON.stringify({hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
|
|
210
|
+
await appendFile(base+'.events','',{mode:0o600})
|
|
211
|
+
await rename(base+'.request.json',base+'.running.json')
|
|
212
|
+
}
|
|
188
213
|
catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
|
|
189
214
|
const task=(async()=>{
|
|
190
215
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
@@ -205,7 +230,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
205
230
|
// Script occurrences come from the authorized run record, never the request.
|
|
206
231
|
if (run?.script) {
|
|
207
232
|
if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
|
|
208
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
233
|
+
const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
209
234
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
210
235
|
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
211
236
|
job=await launch(request.texts,options)
|
|
@@ -237,14 +262,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
237
262
|
// transient native-client probe failure must not reject a model
|
|
238
263
|
// that the host just advertised to the relay and application.
|
|
239
264
|
if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
240
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
265
|
+
const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
241
266
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
242
267
|
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
243
268
|
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
244
269
|
job=await launch(request.texts,options)
|
|
245
270
|
}
|
|
246
271
|
active.set(base,job.child)
|
|
247
|
-
|
|
272
|
+
// Observe completion before process metadata I/O: a fast CLI may
|
|
273
|
+
// already have exited when launch returns, or while that I/O waits.
|
|
274
|
+
const closed=job.child.exitCode!==null||job.child.signalCode!==null
|
|
275
|
+
? Promise.resolve(job.child.exitCode??1)
|
|
276
|
+
: new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
248
277
|
if(signal.aborted)terminateJob(job.child)
|
|
249
278
|
// Scheduled Claude streams every event; forward only the startup summary.
|
|
250
279
|
const startup = observeClaude ? startupObserver() : undefined
|
|
@@ -255,8 +284,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
255
284
|
if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
|
|
256
285
|
})
|
|
257
286
|
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
258
|
-
|
|
259
|
-
|
|
287
|
+
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid,started:job.child.pid ? await processStart(job.child.pid) : undefined,hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
|
|
288
|
+
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{}); const now=new Date();void utimes(base+'.events',now,now).catch(()=>{})},250)
|
|
289
|
+
const code=await closed
|
|
260
290
|
if (cancellation) clearInterval(cancellation)
|
|
261
291
|
await job.cleanup()
|
|
262
292
|
job = undefined
|
|
@@ -290,8 +320,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
290
320
|
}
|
|
291
321
|
|
|
292
322
|
if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
|
293
|
-
const abort=new AbortController()
|
|
323
|
+
const abort=new AbortController(),retire=new AbortController()
|
|
324
|
+
process.once('SIGUSR2',()=>retire.abort())
|
|
294
325
|
for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
|
|
295
326
|
const config=JSON.parse(await readFile(process.argv[2],'utf8'))
|
|
296
|
-
await serveHostExecutor(config,abort.signal)
|
|
327
|
+
await serveHostExecutor(config,abort.signal,undefined,undefined,retire.signal)
|
|
297
328
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { packageVersion } from './version.js'
|
|
|
9
9
|
import {authorizeDeliveryContext} from './delivery-context.mjs'
|
|
10
10
|
import { dispatchChannel } from './channel-backend.js'
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
|
-
import { stat } from 'node:fs/promises'
|
|
12
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
-
import { Scripts } from './scripts.js'
|
|
14
|
+
import { Scripts, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
15
15
|
import { redactFailure } from './failure.js'
|
|
16
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
17
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
@@ -25,12 +25,12 @@ import { isOwner, ownsRun } from './identity.js'
|
|
|
25
25
|
import type { Update } from 'grammy/types'
|
|
26
26
|
import { InboxStore, type IncomingItem } from './inbox.js'
|
|
27
27
|
import { loadConfig, type Config } from './config.js'
|
|
28
|
-
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
28
|
+
import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
|
|
29
29
|
import { ApprovalStore } from './approval.js'
|
|
30
|
-
import { CliUnavailableError, startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
31
|
-
import {
|
|
30
|
+
import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
|
|
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
|
-
import { RunStore, type RunRecord } from './runs.js'
|
|
33
|
+
import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
|
|
34
34
|
import { splitTelegramText } from './reply.js'
|
|
35
35
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
36
36
|
import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
|
|
@@ -38,6 +38,7 @@ import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
|
38
38
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
39
39
|
import { downloadTelegramFile } from './read-request.js'
|
|
40
40
|
import { createConversationMenu } from './conversation-menu.js'
|
|
41
|
+
import { queueSessionClose } from './session-close.js'
|
|
41
42
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
42
43
|
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
|
|
43
44
|
import { discoverDefaults } from './client-defaults.js'
|
|
@@ -77,13 +78,27 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
77
78
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
78
79
|
const conversationMenu = createConversationMenu(control)
|
|
79
80
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome,
|
|
80
|
-
undefined, opencodeDataHome(config.controlDir))
|
|
81
|
+
undefined, opencodeDataHome(config.controlDir), (session) => closeSession(session))
|
|
81
82
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
82
83
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
84
|
+
// Queue the replaced chat session's silent close turn (session-close.ts).
|
|
85
|
+
// A busy relay starts it after the active run, ahead of newer chat input.
|
|
86
|
+
const closeSession = async (session: SessionState | null): Promise<void> => {
|
|
87
|
+
if (config.channelBackendUrl) return
|
|
88
|
+
// The replacement is already saved; a failed close must not fail that reply.
|
|
89
|
+
try {
|
|
90
|
+
const run = await queueSessionClose(runs, (await control.status()).owner, session)
|
|
91
|
+
if (run) void startJob(run).catch((error) => console.error('Conversation close failed to start', safeError(error)))
|
|
92
|
+
} catch (error) { console.error('Conversation close failed to queue', safeError(error)) }
|
|
93
|
+
}
|
|
83
94
|
|
|
84
95
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
85
96
|
let activeBackend = false
|
|
86
97
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
98
|
+
// Host-transport executors outlive this relay; a graceful stop hands them off.
|
|
99
|
+
const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
|
|
100
|
+
const childRuns = new WeakMap<ChildProcess, string>()
|
|
101
|
+
const handedOff = new WeakSet<ChildProcess>()
|
|
87
102
|
let activeChild: ChildProcess | null = null
|
|
88
103
|
let runtimeStarted = false
|
|
89
104
|
let shuttingDown = false
|
|
@@ -114,6 +129,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
114
129
|
format: 'wav',
|
|
115
130
|
}) : undefined,
|
|
116
131
|
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
132
|
+
closeSession: (session) => closeSession(session),
|
|
117
133
|
createTelegramPairing: async (bindingId, owner) => {
|
|
118
134
|
const username = bot?.botInfo?.username
|
|
119
135
|
if (!username || !/^[A-Za-z][A-Za-z0-9_]{4,31}$/.test(username))
|
|
@@ -178,7 +194,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
178
194
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
179
195
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
180
196
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
181
|
-
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external) return
|
|
197
|
+
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled && !run.external && !run.sessionClose) return
|
|
182
198
|
if (!run.scheduled && await runs.running(false)) return
|
|
183
199
|
const owner = (await control.status()).owner
|
|
184
200
|
if (!owner || !ownsRun(owner, run)) {
|
|
@@ -280,8 +296,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
280
296
|
const settle = (change: Partial<SetupAttempt>) => {
|
|
281
297
|
attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
|
|
282
298
|
}
|
|
283
|
-
// Advance only after an eligible
|
|
284
|
-
// occurrence. Setups in an exhausted
|
|
299
|
+
// Advance only after an eligible failure (see attemptFailure) of a current,
|
|
300
|
+
// uncancelled occurrence. Setups in an exhausted scope are skipped and recorded.
|
|
285
301
|
const advance = async (): Promise<AiPreset | undefined> => {
|
|
286
302
|
let preset: AiPreset | undefined
|
|
287
303
|
const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
|
|
@@ -297,16 +313,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
297
313
|
await runs.patch(run.id, { attempts })
|
|
298
314
|
return preset
|
|
299
315
|
}
|
|
300
|
-
// Each attempt is a fresh native session
|
|
316
|
+
// Each attempt is a fresh native session: a rejected Claude attempt already
|
|
317
|
+
// claimed its ID, and a session cannot resume under another login's home.
|
|
301
318
|
const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
|
|
302
319
|
for (;;) {
|
|
303
320
|
const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
|
|
321
|
+
const continuation = continuationLine(started.id, attempts)
|
|
304
322
|
if (ordered) {
|
|
305
|
-
attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', startedAt: new Date().toISOString() }]
|
|
323
|
+
attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', sessionId, startedAt: new Date().toISOString() }]
|
|
306
324
|
await runs.patch(run.id, { attempts })
|
|
307
325
|
}
|
|
308
326
|
try {
|
|
309
|
-
return { ...await launch(texts, script ? {
|
|
327
|
+
return { ...await launch(continuation ? [...texts, continuation] : texts, script ? {
|
|
310
328
|
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
311
329
|
controlDir: config.controlDir, binDir,
|
|
312
330
|
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
@@ -371,6 +389,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
371
389
|
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
372
390
|
if (run.scheduled) background.set(run.id,child)
|
|
373
391
|
else activeChild = child
|
|
392
|
+
childRuns.set(child, run.id)
|
|
374
393
|
const finished = new Promise<number | null>((resolve) => {
|
|
375
394
|
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
376
395
|
else child.once('close', resolve)
|
|
@@ -413,6 +432,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
413
432
|
await withStartLock(async () => {
|
|
414
433
|
try {
|
|
415
434
|
await cleanup()
|
|
435
|
+
// The next relay records a handed-off run's result.
|
|
436
|
+
if (handedOff.has(child)) return
|
|
416
437
|
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
417
438
|
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
418
439
|
if (timedOut) failureReason = 'script-timeout'
|
|
@@ -421,11 +442,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
421
442
|
// A rejection before work is a failure even when the CLI exits 0.
|
|
422
443
|
const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
|
|
423
444
|
if (startup) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
...(failed && rejected?.resetAt ? { resetAt: rejected.resetAt } : {}) })
|
|
445
|
+
const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
|
|
446
|
+
settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure })
|
|
427
447
|
await runs.patch(run.id, { attempts })
|
|
428
|
-
eligible =
|
|
448
|
+
eligible = continues && !interrupted
|
|
429
449
|
}
|
|
430
450
|
if (!eligible) await finalize(code, cancelled, failed)
|
|
431
451
|
} catch (error) {
|
|
@@ -648,8 +668,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
648
668
|
}
|
|
649
669
|
|
|
650
670
|
let outboxWork: Promise<void> | undefined
|
|
651
|
-
const drainOutbox = (onlyRunId?: string): Promise<void> => {
|
|
652
|
-
if (shuttingDown) return outboxWork ?? Promise.resolve()
|
|
671
|
+
const drainOutbox = (onlyRunId?: string, stopping = false): Promise<void> => {
|
|
672
|
+
if (shuttingDown && !stopping) return outboxWork ?? Promise.resolve()
|
|
653
673
|
return outboxWork ?? (outboxWork = (async () => {
|
|
654
674
|
for (const item of await runs.pendingOutbox()) {
|
|
655
675
|
if (onlyRunId && item.runId !== onlyRunId) continue
|
|
@@ -753,6 +773,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
753
773
|
// children, host executor, CLIs) enqueue and query the memory ledger
|
|
754
774
|
// through the shared handler in delivery-socket.ts. The pump below stays
|
|
755
775
|
// the single delivery executor; the socket only admits work into it.
|
|
776
|
+
let drainingHandoff=false
|
|
756
777
|
let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
|
|
757
778
|
const handleDeliveryOp = createLedgerHandler(config.controlDir, {
|
|
758
779
|
wake: () => { void drainOutbox() },
|
|
@@ -815,7 +836,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
815
836
|
const owner = (await control.status()).owner
|
|
816
837
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
817
838
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
818
|
-
const active = await scheduler.
|
|
839
|
+
const active = await scheduler.listTasksReadOnly(await runs.list())
|
|
819
840
|
return { owner, active, text: scheduledTasksText(active, owner) }
|
|
820
841
|
}
|
|
821
842
|
const replyScheduledTasks = async (ctx: Context, number?: number) => {
|
|
@@ -872,6 +893,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
872
893
|
: []),
|
|
873
894
|
].join('\n')
|
|
874
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
|
+
|
|
875
908
|
const cancelPending = async () => {
|
|
876
909
|
let count = 0
|
|
877
910
|
await withStartLock(async () => {
|
|
@@ -1010,16 +1043,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1010
1043
|
if (text === '/stop') {
|
|
1011
1044
|
const running = await runs.running(false)
|
|
1012
1045
|
if ((running && running.pid) || background.size) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
} catch {}
|
|
1017
|
-
if (activeTypingTimer) {
|
|
1046
|
+
let requested = false
|
|
1047
|
+
try { requested = await stopActiveWork() } catch {}
|
|
1048
|
+
if (requested && activeTypingTimer) {
|
|
1018
1049
|
clearInterval(activeTypingTimer)
|
|
1019
1050
|
activeTypingTimer = null
|
|
1020
1051
|
}
|
|
1021
1052
|
await ctx.reply(
|
|
1022
|
-
'🛑 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.',
|
|
1023
1055
|
)
|
|
1024
1056
|
} else {
|
|
1025
1057
|
await ctx.reply('No active run in progress.')
|
|
@@ -1029,7 +1061,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1029
1061
|
|
|
1030
1062
|
if (text === '/new') {
|
|
1031
1063
|
await control.aiState(aiMenu.initial)
|
|
1064
|
+
const previous = await control.getActiveSession()
|
|
1032
1065
|
const next = await control.resetSession()
|
|
1066
|
+
await closeSession(previous)
|
|
1033
1067
|
await ctx.reply(
|
|
1034
1068
|
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>). Agent files and memory preserved.`,
|
|
1035
1069
|
{ parse_mode: 'HTML' },
|
|
@@ -1217,7 +1251,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1217
1251
|
await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
|
|
1218
1252
|
} else if (action === 'new') {
|
|
1219
1253
|
await control.aiState(aiMenu.initial)
|
|
1254
|
+
const previous = await control.getActiveSession()
|
|
1220
1255
|
const next = await control.resetSession()
|
|
1256
|
+
await closeSession(previous)
|
|
1221
1257
|
await ctx.answerCallbackQuery({ text: 'New session started' })
|
|
1222
1258
|
await ctx.reply(
|
|
1223
1259
|
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>).`,
|
|
@@ -1238,11 +1274,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1238
1274
|
} else if (action === 'stop') {
|
|
1239
1275
|
const running = await runs.running(false)
|
|
1240
1276
|
if ((running && running.pid) || background.size) {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
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' })
|
|
1244
1280
|
await ctx.reply(
|
|
1245
|
-
'🛑 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.',
|
|
1246
1283
|
)
|
|
1247
1284
|
} else {
|
|
1248
1285
|
await ctx.answerCallbackQuery({ text: 'No run active' })
|
|
@@ -1259,12 +1296,95 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1259
1296
|
})
|
|
1260
1297
|
}
|
|
1261
1298
|
|
|
1299
|
+
// Watch runs a previous relay handed off until they finish, with the normal
|
|
1300
|
+
// receipts. Never relaunch; existing loops still revoke and cancel them. An
|
|
1301
|
+
// unknown outcome is recorded interrupted, which holds its schedule.
|
|
1302
|
+
const adoptHandoff = async (): Promise<void> => {
|
|
1303
|
+
const handed = await takeRunHandoff(config.controlDir)
|
|
1304
|
+
if (!await hostTransport()) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
|
|
1305
|
+
// Admit every record before the first attach, so no trigger races a hold.
|
|
1306
|
+
const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
|
|
1307
|
+
for (const run of admitted) await withStartLock(async () => {
|
|
1308
|
+
let child: ChildProcess
|
|
1309
|
+
try {
|
|
1310
|
+
// The foreground lane is serial; a second foreground handoff is corrupt.
|
|
1311
|
+
if (!run.scheduled && activeChild) throw new Error('A second handed-off foreground run was cancelled; its outcome is unknown')
|
|
1312
|
+
child = await attachHostJob(config.controlDir, run.id)
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
// Never leave an unobserved executor: ask the host to stop it.
|
|
1315
|
+
await mkdir(join(config.controlDir, 'host-executor'), { recursive: true, mode: 0o700 })
|
|
1316
|
+
.then(() => writeFile(join(config.controlDir, 'host-executor', run.id + '.cancel'), '', { mode: 0o600 })).catch(() => {})
|
|
1317
|
+
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, safeError(error)) })
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
// Listen before any await so a fast exit cannot be missed.
|
|
1321
|
+
const closed = new Promise<number | null>(resolve => child.once('close', resolve))
|
|
1322
|
+
let errorTail = '', outputTail = '', timedOut = false
|
|
1323
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { if (run.script) outputTail = (outputTail + chunk).slice(-16384) })
|
|
1324
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
1325
|
+
errorTail = (errorTail + chunk).slice(-16384)
|
|
1326
|
+
if (run.script) outputTail = (outputTail + chunk).slice(-16384)
|
|
1327
|
+
})
|
|
1328
|
+
if (run.scheduled) background.set(run.id, child)
|
|
1329
|
+
else activeChild = child
|
|
1330
|
+
childRuns.set(child, run.id)
|
|
1331
|
+
await runs.patch(run.id, { pid: child.pid })
|
|
1332
|
+
console.info('run adopted', { run_id: run.id, pid: child.pid })
|
|
1333
|
+
// A script keeps its registered bound from its original start.
|
|
1334
|
+
const limit = run.script ? await new Scripts(config.controlDir).get(run.script.id).then(script => script.timeoutSeconds, () => DEFAULT_SCRIPT_TIMEOUT_SECONDS) : 0
|
|
1335
|
+
const timer = limit ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0, Date.parse(run.startedAt ?? run.createdAt) + limit * 1000 - Date.now())) : undefined
|
|
1336
|
+
const completion = closed.then(code => withStartLock(async () => {
|
|
1337
|
+
clearTimeout(timer)
|
|
1338
|
+
if (run.scheduled) background.delete(run.id)
|
|
1339
|
+
else if (activeChild === child) activeChild = null
|
|
1340
|
+
if (handedOff.has(child)) return
|
|
1341
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
1342
|
+
const failed = !cancelled && code !== 0
|
|
1343
|
+
const status = cancelled ? 'cancelled' : failed ? 'failed' : 'completed'
|
|
1344
|
+
const unknown = errorTail.includes('outcome is unknown'), transport = errorTail.includes('Host executor client interrupted by')
|
|
1345
|
+
const failureReason = timedOut ? 'script-timeout' : unknown ? 'handoff-outcome-unknown' : transport ? 'host-executor-transport-interrupted'
|
|
1346
|
+
: errorTail.includes('Host CLI executor is offline') ? 'host-executor-offline' : 'executor-exit'
|
|
1347
|
+
if (code === 0 && run.execution && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(run.execution.sessionId)
|
|
1348
|
+
const at = new Date().toISOString(), last = run.attempts?.at(-1)
|
|
1349
|
+
await runs.patch(run.id, { status, endedAt: at, exitCode: code,
|
|
1350
|
+
...(last?.outcome === 'running' ? { attempts: [...run.attempts!.slice(0, -1), { ...last, outcome: status, exitCode: code, endedAt: at }] } : {}),
|
|
1351
|
+
...(run.script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
1352
|
+
...(failed ? { failureReason, interrupted: unknown || transport, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
1353
|
+
await releaseExternal(run)
|
|
1354
|
+
console.info('run ended', { run_id: run.id, code, adopted: true })
|
|
1355
|
+
})).then(async () => {
|
|
1356
|
+
const next = await runs.nextQueued()
|
|
1357
|
+
if (next && !shuttingDown) await startJob(next)
|
|
1358
|
+
}).catch(error => console.error('Adopted run completion failed', safeError(error)))
|
|
1359
|
+
completions.add(completion)
|
|
1360
|
+
void completion.finally(() => completions.delete(completion))
|
|
1361
|
+
}).catch(error => console.error('Run adoption failed', run.id, safeError(error)))
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1262
1364
|
let stopWork: Promise<void> | undefined
|
|
1263
1365
|
const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
|
|
1264
1366
|
shuttingDown = true
|
|
1265
1367
|
runtimeStarted = false
|
|
1266
1368
|
pollingAbort.abort()
|
|
1267
|
-
|
|
1369
|
+
let handedRecords:RunRecord[]=[]
|
|
1370
|
+
// Hand off running host work before any wait can outlast the stop grace
|
|
1371
|
+
// period. The lock lets an in-flight launch register; later ones see shutdown.
|
|
1372
|
+
if (await hostTransport()) await withStartLock(async () => {
|
|
1373
|
+
const handoff = new Map<ChildProcess, RunRecord>()
|
|
1374
|
+
for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) {
|
|
1375
|
+
const run = childRuns.has(child) ? await runs.get(childRuns.get(child)!) : null
|
|
1376
|
+
if (run?.status === 'running' && !ownerStopped.has(child) && child.exitCode === null && child.signalCode === null) handoff.set(child, run)
|
|
1377
|
+
}
|
|
1378
|
+
if (!handoff.size) return
|
|
1379
|
+
try { handedRecords=[...handoff.values()];await saveRunHandoff(config.controlDir, handedRecords) }
|
|
1380
|
+
catch (error) { console.error('Run handoff failed; stopping host work', safeError(error)); return }
|
|
1381
|
+
// SIGKILL leaves no cancel marker, so the host keeps each executor running.
|
|
1382
|
+
for (const child of handoff.keys()) { handedOff.add(child); child.kill('SIGKILL') }
|
|
1383
|
+
})
|
|
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)
|
|
1268
1388
|
deliveryServer = null
|
|
1269
1389
|
await applicationChannel.stop()
|
|
1270
1390
|
wakePollRetry?.()
|
|
@@ -1275,8 +1395,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1275
1395
|
await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
|
|
1276
1396
|
// Finish registering in-flight launches before taking the child snapshot.
|
|
1277
1397
|
await withStartLock(async () => {
|
|
1278
|
-
|
|
1279
|
-
for (const child of background.values()) terminateJob(child)
|
|
1398
|
+
for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) if (!handedOff.has(child)) terminateJob(child)
|
|
1280
1399
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
1281
1400
|
})
|
|
1282
1401
|
await Promise.all(completions)
|
|
@@ -1300,13 +1419,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1300
1419
|
throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
|
|
1301
1420
|
const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
|
|
1302
1421
|
deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
|
|
1422
|
+
beforeClose:async()=>{if(drainingHandoff){await outboxWork?.catch(()=>{});await drainOutbox(undefined,true)}},
|
|
1303
1423
|
...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
|
|
1304
1424
|
})
|
|
1425
|
+
// Reconcile, then adopt handed-off runs, before any intake can launch work.
|
|
1426
|
+
await scheduler.recover(runs)
|
|
1427
|
+
await adoptHandoff()
|
|
1305
1428
|
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1306
1429
|
runtimeStarted = true
|
|
1307
1430
|
const owner = (await control.status()).owner
|
|
1308
1431
|
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1309
|
-
await scheduler.recover(runs)
|
|
1310
1432
|
sourceTimer = setInterval(() => {
|
|
1311
1433
|
void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
|
|
1312
1434
|
}, 1000)
|
|
@@ -1380,7 +1502,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1380
1502
|
}
|
|
1381
1503
|
}
|
|
1382
1504
|
}
|
|
1383
|
-
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1505
|
+
return { bot: bot!, telegramEnabled, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, adoptHandoff, applicationChannel }
|
|
1384
1506
|
}
|
|
1385
1507
|
|
|
1386
1508
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/menu.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
|
-
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
6
|
+
import { ControlStore, type ControlGuard, type SessionState } from './control-state.js'
|
|
7
7
|
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
9
|
import { claudeConfigHome } from './executor.js'
|
|
@@ -31,7 +31,7 @@ const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
|
31
31
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
32
32
|
// These are operational settings, not a second conversational/agent loop.
|
|
33
33
|
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd(), codexHome?: string,
|
|
34
|
-
isInstalled = installed, opencodeDataHome?: string) => {
|
|
34
|
+
isInstalled = installed, opencodeDataHome?: string, closeSession?: (session: SessionState | null) => Promise<void>) => {
|
|
35
35
|
const initial = chatPreset(cli)
|
|
36
36
|
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
37
37
|
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
@@ -68,6 +68,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
68
68
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
69
69
|
const fresh = !sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
|
|
70
70
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
71
|
+
if (fresh) await closeSession?.(session)
|
|
71
72
|
return { preset, fresh }
|
|
72
73
|
}
|
|
73
74
|
const choose = async (ctx: Context, preset: AiPreset) => {
|