@phnx-labs/agents-cli 1.22.31 → 1.22.32
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 +66 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +44 -10
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- package/package.json +2 -2
package/dist/lib/runner.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawn, execFileSync } from 'child_process';
|
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import * as os from 'os';
|
|
20
|
-
import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, } from './routines.js';
|
|
20
|
+
import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, resolveJobExecutionContext, slotRunId, claimRunSlot, readRunMeta, resolveHostStrategy, } from './routines.js';
|
|
21
21
|
import { getRunsDir, getUserAgentsDir } from './state.js';
|
|
22
22
|
import { shortCodexHome } from './codex-home.js';
|
|
23
23
|
import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
|
|
@@ -38,7 +38,6 @@ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rot
|
|
|
38
38
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
39
39
|
import { machineId } from './machine-id.js';
|
|
40
40
|
import { isSelfUpdatingAgent, ROUTINE_AGENT_COMMANDS as AGENT_COMMANDS, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from './agents.js';
|
|
41
|
-
import { expandLocalHome, getProjectRoot } from './project-root.js';
|
|
42
41
|
export class RoutineAlreadyRunningError extends Error {
|
|
43
42
|
constructor(jobName, runId) {
|
|
44
43
|
super(`Routine '${jobName}' already has a running execution (${runId})`);
|
|
@@ -65,7 +64,10 @@ function activeRoutineRun(config) {
|
|
|
65
64
|
}
|
|
66
65
|
return null;
|
|
67
66
|
}
|
|
68
|
-
|
|
67
|
+
/** Serialize concurrent launches of one routine on this box (the on-disk claim
|
|
68
|
+
* primitive `claimRunSlot` is what holds across processes; this lock only makes
|
|
69
|
+
* the allocate→spawn window atomic within this process's launch paths). */
|
|
70
|
+
async function withRoutineLock(config, launch) {
|
|
69
71
|
const target = path.join(getJobRunsDir(config.name), '.launch-claim');
|
|
70
72
|
ensureLockTarget(target, '', 0o700);
|
|
71
73
|
const release = await lockfile.lock(target, {
|
|
@@ -78,15 +80,237 @@ async function withRoutineLaunchClaim(config, launch) {
|
|
|
78
80
|
},
|
|
79
81
|
});
|
|
80
82
|
try {
|
|
81
|
-
const active = activeRoutineRun(config);
|
|
82
|
-
if (active)
|
|
83
|
-
throw new RoutineAlreadyRunningError(config.name, active.runId);
|
|
84
83
|
return await launch();
|
|
85
84
|
}
|
|
86
85
|
finally {
|
|
87
86
|
await release();
|
|
88
87
|
}
|
|
89
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Reject placement/body combinations the runner cannot execute — before the
|
|
91
|
+
* attempt is allocated, so an invalid config (host+workflow, cloud+command, …)
|
|
92
|
+
* throws cleanly rather than allocating a run record it can never satisfy. These
|
|
93
|
+
* mirror the defensive guards inside `executeJobOnHost`/`executeJobOnCloud`;
|
|
94
|
+
* `validateJob` already rejects the same combinations at add/edit time.
|
|
95
|
+
*/
|
|
96
|
+
function assertRunnablePlacement(config) {
|
|
97
|
+
const strategy = resolveHostStrategy(config);
|
|
98
|
+
if (strategy === 'host' || strategy === 'fleet') {
|
|
99
|
+
if (config.workflow)
|
|
100
|
+
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
101
|
+
if (config.loop)
|
|
102
|
+
throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute on a host yet — remove 'host:' or 'loop:'.`);
|
|
103
|
+
if (config.command)
|
|
104
|
+
throw new Error(`Routine '${config.name}' uses 'command:', which can't execute on a host yet — remove 'host:' or 'command:'.`);
|
|
105
|
+
}
|
|
106
|
+
if (strategy === 'cloud') {
|
|
107
|
+
if (config.workflow)
|
|
108
|
+
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
|
|
109
|
+
if (config.loop)
|
|
110
|
+
throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'loop:'.`);
|
|
111
|
+
if (config.command)
|
|
112
|
+
throw new Error(`Routine '${config.name}' uses 'command:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'command:'.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Write a pre-execution terminal record (blocked/skipped) that spawns nothing. */
|
|
116
|
+
function writeTerminalRecord(config, runId, status, trigger, extra) {
|
|
117
|
+
fs.mkdirSync(getRunDir(config.name, runId), { recursive: true });
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
const scheduledForIso = trigger.scheduledFor
|
|
120
|
+
? (typeof trigger.scheduledFor === 'string' ? trigger.scheduledFor : trigger.scheduledFor.toISOString())
|
|
121
|
+
: undefined;
|
|
122
|
+
const meta = {
|
|
123
|
+
jobName: config.name,
|
|
124
|
+
runId,
|
|
125
|
+
...runProvenance(config),
|
|
126
|
+
...(config.workflow ? { workflow: config.workflow } : config.command ? { command: config.command } : config.agent ? { agent: config.agent } : {}),
|
|
127
|
+
triggerKind: trigger.kind,
|
|
128
|
+
...(scheduledForIso ? { scheduledFor: scheduledForIso } : {}),
|
|
129
|
+
pid: null,
|
|
130
|
+
spawnedAt: Date.now(),
|
|
131
|
+
status,
|
|
132
|
+
startedAt: now,
|
|
133
|
+
completedAt: now,
|
|
134
|
+
exitCode: null,
|
|
135
|
+
duration: 0,
|
|
136
|
+
...extra,
|
|
137
|
+
};
|
|
138
|
+
writeRunMeta(meta);
|
|
139
|
+
return meta;
|
|
140
|
+
}
|
|
141
|
+
/** Persist the cross-entry-point active claim before releasing the launch lock. */
|
|
142
|
+
function writeActiveClaim(config, attempt) {
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
const meta = {
|
|
145
|
+
jobName: config.name,
|
|
146
|
+
runId: attempt.runId,
|
|
147
|
+
...runProvenance(config),
|
|
148
|
+
...attempt.stamp,
|
|
149
|
+
...(config.workflow ? { workflow: config.workflow } : config.command ? { command: config.command } : config.agent ? { agent: config.agent } : {}),
|
|
150
|
+
// The launcher owns this provisional claim until the command/agent child
|
|
151
|
+
// replaces it with its own pid. Recording the owner makes a crash between
|
|
152
|
+
// lock release and child spawn recoverable instead of wedging the routine
|
|
153
|
+
// for its full configured timeout.
|
|
154
|
+
pid: process.pid,
|
|
155
|
+
// `isPidOurs` compares this value with the OS process birth time. The
|
|
156
|
+
// daemon may have been alive for days before claiming a routine, so the
|
|
157
|
+
// claim must carry the launcher's birth, not the claim timestamp.
|
|
158
|
+
spawnedAt: Date.now() - process.uptime() * 1000,
|
|
159
|
+
status: 'running',
|
|
160
|
+
startedAt: now,
|
|
161
|
+
completedAt: null,
|
|
162
|
+
exitCode: null,
|
|
163
|
+
timeoutMs: parseTimeout(config.timeout) || 10 * 60 * 1000,
|
|
164
|
+
};
|
|
165
|
+
writeRunMeta(meta);
|
|
166
|
+
return meta;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Allocate a routine attempt BEFORE any placement / version / sandbox / preflight
|
|
170
|
+
* / dispatch work, so every rejected, skipped, or blocked fire leaves exactly one
|
|
171
|
+
* visible terminal record. Runs inside {@link withRoutineLock}. Enforces, in order:
|
|
172
|
+
*
|
|
173
|
+
* 1. single-fire — a scheduled slot atomically claims its (routine, UTC) run
|
|
174
|
+
* directory; a duplicate cron delivery for the same slot returns the original
|
|
175
|
+
* attempt and spawns nothing.
|
|
176
|
+
* 2. non-overlap — a prior run of this routine that is still active makes this
|
|
177
|
+
* attempt a `skipped`/`active_run` record linking the live run.
|
|
178
|
+
* 3. readiness — an unresolved/blocked execution context (bad cwd, non-portable
|
|
179
|
+
* path, missing project base, …) makes this a `blocked` record.
|
|
180
|
+
*/
|
|
181
|
+
function allocateRoutineAttempt(config, trigger) {
|
|
182
|
+
const scheduledForIso = trigger.scheduledFor
|
|
183
|
+
? (typeof trigger.scheduledFor === 'string' ? trigger.scheduledFor : trigger.scheduledFor.toISOString())
|
|
184
|
+
: undefined;
|
|
185
|
+
// 1. Slot claim (schedule/catchup). Manual/webhook/event fires get a fresh id.
|
|
186
|
+
let runId;
|
|
187
|
+
if (scheduledForIso) {
|
|
188
|
+
runId = slotRunId(scheduledForIso);
|
|
189
|
+
if (!claimRunSlot(config.name, runId)) {
|
|
190
|
+
const existing = readRunMeta(config.name, runId);
|
|
191
|
+
if (existing)
|
|
192
|
+
return { proceed: false, terminal: existing };
|
|
193
|
+
return {
|
|
194
|
+
proceed: false,
|
|
195
|
+
terminal: writeTerminalRecord(config, generateRunId(), 'skipped', trigger, {
|
|
196
|
+
skipReason: 'duplicate_slot',
|
|
197
|
+
activeRunId: runId,
|
|
198
|
+
errorMessage: 'duplicate schedule-slot delivery — the slot is already claimed',
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
runId = generateRunId();
|
|
205
|
+
fs.mkdirSync(getRunDir(config.name, runId), { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
// 2. Non-overlap: a prior run of THIS routine is still live.
|
|
208
|
+
const active = activeRoutineRun(config);
|
|
209
|
+
if (active && active.runId !== runId) {
|
|
210
|
+
return {
|
|
211
|
+
proceed: false,
|
|
212
|
+
terminal: writeTerminalRecord(config, runId, 'skipped', trigger, {
|
|
213
|
+
skipReason: 'active_run',
|
|
214
|
+
activeRunId: active.runId,
|
|
215
|
+
errorMessage: `skipped — '${config.name}' already has an active run (${active.runId})`,
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const eligibility = checkJobDeviceEligibility(config);
|
|
220
|
+
if (eligibility) {
|
|
221
|
+
return {
|
|
222
|
+
proceed: false,
|
|
223
|
+
terminal: writeTerminalRecord(config, runId, 'skipped', trigger, {
|
|
224
|
+
skipReason: 'wrong_owner',
|
|
225
|
+
errorMessage: eligibility.message,
|
|
226
|
+
}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (!config.workflow && config.agent && ROUTINE_AGENT_IDS.includes(config.agent) && isAgentHardDeprecated(config.agent)) {
|
|
230
|
+
const reason = hardDeprecationError(config.agent);
|
|
231
|
+
return {
|
|
232
|
+
proceed: false,
|
|
233
|
+
terminal: writeTerminalRecord(config, runId, 'blocked', trigger, {
|
|
234
|
+
readiness: { code: 'agent_unavailable', message: reason },
|
|
235
|
+
errorMessage: reason,
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
assertRunnablePlacement(config);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
const message = err.message;
|
|
244
|
+
return {
|
|
245
|
+
proceed: false,
|
|
246
|
+
terminal: writeTerminalRecord(config, runId, 'blocked', trigger, {
|
|
247
|
+
readiness: { code: 'placement_unsupported', message },
|
|
248
|
+
errorMessage: message,
|
|
249
|
+
}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// 3. Readiness: resolve the execution context for the routine's placement.
|
|
253
|
+
// Local placement inspects this box's filesystem; host/fleet/cloud defer
|
|
254
|
+
// existence (the remote fs is unreachable here) and enforce portability only.
|
|
255
|
+
const mode = resolveHostStrategy(config);
|
|
256
|
+
const ctx = resolveJobExecutionContext(config, {
|
|
257
|
+
mode,
|
|
258
|
+
probe: mode === 'local' ? undefined : null,
|
|
259
|
+
});
|
|
260
|
+
const stamp = {
|
|
261
|
+
triggerKind: trigger.kind,
|
|
262
|
+
...(scheduledForIso ? { scheduledFor: scheduledForIso } : {}),
|
|
263
|
+
...(config.project ? { project: config.project } : {}),
|
|
264
|
+
...(config.cwd ? { requestedCwd: config.cwd } : {}),
|
|
265
|
+
...(ctx.resolvedCwd ? { resolvedCwd: ctx.resolvedCwd } : {}),
|
|
266
|
+
};
|
|
267
|
+
if (!ctx.ready) {
|
|
268
|
+
return {
|
|
269
|
+
proceed: false,
|
|
270
|
+
terminal: writeTerminalRecord(config, runId, 'blocked', trigger, {
|
|
271
|
+
...stamp,
|
|
272
|
+
readiness: ctx.readiness,
|
|
273
|
+
errorMessage: `blocked: ${ctx.readiness?.code ?? 'not_ready'}${ctx.readiness?.message ? ` — ${ctx.readiness.message}` : ''}`,
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return { proceed: true, attempt: { runId, stamp } };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Claim one routine attempt under the short launch lock, then execute after
|
|
281
|
+
* releasing it. The persisted `running` claim makes a concurrent entry point
|
|
282
|
+
* skip immediately instead of waiting for a foreground run to finish. `run` is
|
|
283
|
+
* the placement/command/agent dispatch, invoked only when the attempt proceeds;
|
|
284
|
+
* `wrapTerminal` adapts a pre-spawn terminal record into the caller's return type.
|
|
285
|
+
*/
|
|
286
|
+
async function runWithAttempt(config, trigger, run, wrapTerminal) {
|
|
287
|
+
const claimed = await withRoutineLock(config, async () => {
|
|
288
|
+
const alloc = allocateRoutineAttempt(config, trigger);
|
|
289
|
+
if (!alloc.proceed)
|
|
290
|
+
return { terminal: alloc.terminal };
|
|
291
|
+
writeActiveClaim(config, alloc.attempt);
|
|
292
|
+
return { attempt: alloc.attempt };
|
|
293
|
+
});
|
|
294
|
+
if ('terminal' in claimed)
|
|
295
|
+
return wrapTerminal(claimed.terminal);
|
|
296
|
+
try {
|
|
297
|
+
return await run(claimed.attempt);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const message = err.message;
|
|
301
|
+
const existing = readRunMeta(config.name, claimed.attempt.runId);
|
|
302
|
+
if (existing) {
|
|
303
|
+
finalizeRunMeta(existing, 'failed', 1, { errorMessage: message });
|
|
304
|
+
writeRunMeta(existing);
|
|
305
|
+
return wrapTerminal(existing);
|
|
306
|
+
}
|
|
307
|
+
return wrapTerminal(writeTerminalRecord(config, claimed.attempt.runId, 'blocked', trigger, {
|
|
308
|
+
...claimed.attempt.stamp,
|
|
309
|
+
readiness: { code: 'target_unreachable', message },
|
|
310
|
+
errorMessage: message,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
90
314
|
function terminateRoutineTree(pid) {
|
|
91
315
|
if (!pid)
|
|
92
316
|
return;
|
|
@@ -131,17 +355,18 @@ const ROUTINE_TRANSCRIPT_SPECS = {
|
|
|
131
355
|
// Muse: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
|
|
132
356
|
muse: [{ root: ['.local', 'share', 'muse', 'sessions'], ext: '.jsonl' }],
|
|
133
357
|
};
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Working directory for a routine's LOCAL child, resolved from its explicit
|
|
360
|
+
* `project`/`cwd` execution anchor via {@link resolveJobExecutionContext} — never
|
|
361
|
+
* inferred from `repo` (which is external repository identity only) and never the
|
|
362
|
+
* daemon's launch cwd. A command routine with neither field lands in `$HOME`
|
|
363
|
+
* (housekeeping); an unresolved/blocked agent context also falls back to `$HOME`
|
|
364
|
+
* so a caller that reaches this (past the readiness gate) has a valid directory,
|
|
365
|
+
* but the gate should have paused such a routine before it ever spawned.
|
|
366
|
+
*/
|
|
367
|
+
export function routineSpawnCwd(config) {
|
|
368
|
+
const ctx = resolveJobExecutionContext(config, { mode: 'local' });
|
|
369
|
+
return ctx.absoluteCwd ?? os.homedir();
|
|
145
370
|
}
|
|
146
371
|
/** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
|
|
147
372
|
export function buildJobCommand(config, resolvedPrompt) {
|
|
@@ -772,53 +997,13 @@ function injectRoutineActor(env, config) {
|
|
|
772
997
|
env.AGENTS_ACTOR = config.actor;
|
|
773
998
|
return env;
|
|
774
999
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
* at fire time — the daemon would happily build and spawn `gemini …` against a
|
|
781
|
-
* backend Google retired. Fail loud here, before any version/account
|
|
782
|
-
* resolution or sandbox prep, so a legacy routine skips with the same message
|
|
783
|
-
* every other entry point (`sync`, `exec`, `import`, `run-cloud`, `versions`)
|
|
784
|
-
* already shows instead of a doomed, confusing spawn failure.
|
|
785
|
-
*/
|
|
786
|
-
function hardDeprecationRunFailure(config, agent) {
|
|
787
|
-
const runId = generateRunId();
|
|
788
|
-
const runDir = getRunDir(config.name, runId);
|
|
789
|
-
fs.mkdirSync(runDir, { recursive: true });
|
|
790
|
-
const reason = hardDeprecationError(agent);
|
|
791
|
-
const meta = {
|
|
792
|
-
jobName: config.name,
|
|
793
|
-
runId,
|
|
794
|
-
...runProvenance(config),
|
|
795
|
-
agent,
|
|
796
|
-
pid: null,
|
|
797
|
-
spawnedAt: Date.now(),
|
|
798
|
-
status: 'running',
|
|
799
|
-
startedAt: new Date().toISOString(),
|
|
800
|
-
completedAt: null,
|
|
801
|
-
exitCode: null,
|
|
802
|
-
};
|
|
803
|
-
writeRunMeta(meta);
|
|
804
|
-
process.stderr.write(`[agents] routine ${config.name}: ${reason}\n`);
|
|
805
|
-
finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
|
|
806
|
-
writeRunMeta(meta);
|
|
807
|
-
return meta;
|
|
1000
|
+
export async function executeJob(config, deps, trigger = { kind: 'manual' }) {
|
|
1001
|
+
// Unified attempt: allocate + persist the run record (or a terminal blocked/
|
|
1002
|
+
// skipped record) BEFORE placement, version selection, sandbox, or preflight —
|
|
1003
|
+
// and hold the active-run claim so a manual run cannot overlap a scheduled one.
|
|
1004
|
+
return runWithAttempt(config, trigger, (attempt) => executeJobPlaced(config, deps, attempt), (meta) => ({ meta, reportPath: null }));
|
|
808
1005
|
}
|
|
809
|
-
|
|
810
|
-
const eligibility = checkJobDeviceEligibility(config);
|
|
811
|
-
if (eligibility) {
|
|
812
|
-
throw new Error(eligibility.message);
|
|
813
|
-
}
|
|
814
|
-
// Hard-deprecated harness (e.g. gemini): fail loud before placement, version/
|
|
815
|
-
// account resolution, or sandbox prep — local, host, AND cloud placement all
|
|
816
|
-
// share this one gate (a hard-deprecated agent has no `cloudProvider` entry,
|
|
817
|
-
// so cloud placement would otherwise silently fall back to the default
|
|
818
|
-
// provider instead of refusing). See hardDeprecationRunFailure.
|
|
819
|
-
if (!config.workflow && config.agent && ROUTINE_AGENT_IDS.includes(config.agent) && isAgentHardDeprecated(config.agent)) {
|
|
820
|
-
return { meta: hardDeprecationRunFailure(config, config.agent), reportPath: null };
|
|
821
|
-
}
|
|
1006
|
+
async function executeJobPlaced(config, deps, attempt) {
|
|
822
1007
|
// Placement (hostStrategy / bare host:) — body may run on another machine
|
|
823
1008
|
// over SSH or in the cloud; local version selection / sandbox / spawn then
|
|
824
1009
|
// do not apply. Sync callers (manual `routines run`, catchup) follow the
|
|
@@ -827,17 +1012,17 @@ export async function executeJob(config, deps) {
|
|
|
827
1012
|
const { resolvePlacementTarget } = await import('./routines-placement.js');
|
|
828
1013
|
const target = resolvePlacementTarget(config);
|
|
829
1014
|
if (target.mode === 'host') {
|
|
830
|
-
return executeJobOnHost({ ...config, host: target.host }, { detached: false });
|
|
1015
|
+
return executeJobOnHost({ ...config, host: target.host }, { detached: false }, attempt);
|
|
831
1016
|
}
|
|
832
1017
|
if (target.mode === 'cloud') {
|
|
833
|
-
return executeJobOnCloud(config, { detached: false });
|
|
1018
|
+
return executeJobOnCloud(config, { detached: false }, attempt);
|
|
834
1019
|
}
|
|
835
1020
|
}
|
|
836
1021
|
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
837
1022
|
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
838
1023
|
// list/runs/overdue keep working.
|
|
839
1024
|
if (config.command) {
|
|
840
|
-
return executeCommandJobForeground(config);
|
|
1025
|
+
return executeCommandJobForeground(config, attempt);
|
|
841
1026
|
}
|
|
842
1027
|
const launch = await resolveRoutineLaunch(config);
|
|
843
1028
|
const primaryVersion = launch.chain[0]?.version ?? config.version;
|
|
@@ -856,7 +1041,7 @@ export async function executeJob(config, deps) {
|
|
|
856
1041
|
// `config.sandbox` (see the resume branch in buildJobCommand).
|
|
857
1042
|
const useSandbox = config.sandbox !== false && !config.resume;
|
|
858
1043
|
const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
|
|
859
|
-
const runId =
|
|
1044
|
+
const runId = attempt.runId;
|
|
860
1045
|
const runDir = getRunDir(config.name, runId);
|
|
861
1046
|
fs.mkdirSync(runDir, { recursive: true });
|
|
862
1047
|
const baseEnv = injectRoutineActor(useSandbox
|
|
@@ -870,6 +1055,7 @@ export async function executeJob(config, deps) {
|
|
|
870
1055
|
jobName: config.name,
|
|
871
1056
|
runId,
|
|
872
1057
|
...runProvenance(config),
|
|
1058
|
+
...attempt.stamp,
|
|
873
1059
|
agent: effectiveAgent,
|
|
874
1060
|
version: primaryVersion,
|
|
875
1061
|
...(config.workflow ? { workflow: config.workflow } : {}),
|
|
@@ -1070,7 +1256,7 @@ export async function executeJob(config, deps) {
|
|
|
1070
1256
|
* default). Writes a local run record with `cloudTaskId` so list/runs still
|
|
1071
1257
|
* work; does not wait for cloud completion on the detached path.
|
|
1072
1258
|
*/
|
|
1073
|
-
async function executeJobOnCloud(config, opts) {
|
|
1259
|
+
async function executeJobOnCloud(config, opts, attempt) {
|
|
1074
1260
|
if (config.workflow) {
|
|
1075
1261
|
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
|
|
1076
1262
|
}
|
|
@@ -1094,13 +1280,14 @@ async function executeJobOnCloud(config, opts) {
|
|
|
1094
1280
|
...redactPrompt(config.prompt),
|
|
1095
1281
|
schedule: config.schedule,
|
|
1096
1282
|
});
|
|
1097
|
-
const runId =
|
|
1283
|
+
const runId = attempt.runId;
|
|
1098
1284
|
const runDir = getRunDir(config.name, runId);
|
|
1099
1285
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1100
1286
|
const meta = {
|
|
1101
1287
|
jobName: config.name,
|
|
1102
1288
|
runId,
|
|
1103
1289
|
...runProvenance(config),
|
|
1290
|
+
...attempt.stamp,
|
|
1104
1291
|
agent: config.agent,
|
|
1105
1292
|
pid: null,
|
|
1106
1293
|
spawnedAt: Date.now(),
|
|
@@ -1146,7 +1333,7 @@ async function executeJobOnCloud(config, opts) {
|
|
|
1146
1333
|
throw err;
|
|
1147
1334
|
}
|
|
1148
1335
|
}
|
|
1149
|
-
async function executeJobOnHost(config, opts) {
|
|
1336
|
+
async function executeJobOnHost(config, opts, attempt) {
|
|
1150
1337
|
if (config.workflow) {
|
|
1151
1338
|
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
1152
1339
|
}
|
|
@@ -1158,6 +1345,11 @@ async function executeJobOnHost(config, opts) {
|
|
|
1158
1345
|
}
|
|
1159
1346
|
const { resolveHostRunTarget, dispatchPromptToHost } = await import('./hosts/run-target.js');
|
|
1160
1347
|
const host = await resolveHostRunTarget(config.host);
|
|
1348
|
+
const { evaluateHostActivationReadiness } = await import('./routine-readiness.js');
|
|
1349
|
+
const readiness = await evaluateHostActivationReadiness(config);
|
|
1350
|
+
if (!readiness.ready)
|
|
1351
|
+
throw new Error(`${readiness.readiness?.code ?? 'not_ready'}: ${readiness.readiness?.message ?? 'target is not ready'}`);
|
|
1352
|
+
const remoteCwd = readiness.context.resolvedCwd;
|
|
1161
1353
|
const timer = createTimer('agent.run', {
|
|
1162
1354
|
agent: config.agent,
|
|
1163
1355
|
jobName: config.name,
|
|
@@ -1166,13 +1358,14 @@ async function executeJobOnHost(config, opts) {
|
|
|
1166
1358
|
...redactPrompt(config.prompt),
|
|
1167
1359
|
schedule: config.schedule,
|
|
1168
1360
|
});
|
|
1169
|
-
const runId =
|
|
1361
|
+
const runId = attempt.runId;
|
|
1170
1362
|
const runDir = getRunDir(config.name, runId);
|
|
1171
1363
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1172
1364
|
const meta = {
|
|
1173
1365
|
jobName: config.name,
|
|
1174
1366
|
runId,
|
|
1175
1367
|
...runProvenance(config),
|
|
1368
|
+
...attempt.stamp,
|
|
1176
1369
|
agent: config.agent,
|
|
1177
1370
|
pid: null, // no local process — the run lives on the host
|
|
1178
1371
|
spawnedAt: Date.now(),
|
|
@@ -1190,7 +1383,7 @@ async function executeJobOnHost(config, opts) {
|
|
|
1190
1383
|
effort: config.effort,
|
|
1191
1384
|
model: config.config?.model,
|
|
1192
1385
|
timeout: config.timeout, // enforced by the REMOTE agents run
|
|
1193
|
-
remoteCwd
|
|
1386
|
+
remoteCwd,
|
|
1194
1387
|
name: config.name,
|
|
1195
1388
|
cwd: runDir,
|
|
1196
1389
|
follow: !opts.detached,
|
|
@@ -1206,13 +1399,13 @@ async function executeJobOnHost(config, opts) {
|
|
|
1206
1399
|
return { meta, reportPath: null };
|
|
1207
1400
|
}
|
|
1208
1401
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
1209
|
-
async function executeCommandJobForeground(config) {
|
|
1402
|
+
async function executeCommandJobForeground(config, attempt) {
|
|
1210
1403
|
const timer = createTimer('agent.run', {
|
|
1211
1404
|
jobName: config.name,
|
|
1212
1405
|
mode: config.mode,
|
|
1213
1406
|
schedule: config.schedule,
|
|
1214
1407
|
});
|
|
1215
|
-
const runId =
|
|
1408
|
+
const runId = attempt.runId;
|
|
1216
1409
|
const runDir = getRunDir(config.name, runId);
|
|
1217
1410
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1218
1411
|
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
@@ -1221,6 +1414,7 @@ async function executeCommandJobForeground(config) {
|
|
|
1221
1414
|
jobName: config.name,
|
|
1222
1415
|
runId,
|
|
1223
1416
|
...runProvenance(config),
|
|
1417
|
+
...attempt.stamp,
|
|
1224
1418
|
command: config.command,
|
|
1225
1419
|
pid: null,
|
|
1226
1420
|
spawnedAt: Date.now(),
|
|
@@ -1300,23 +1494,10 @@ function safeHook(fn) {
|
|
|
1300
1494
|
catch { /* hooks are best-effort */ }
|
|
1301
1495
|
}
|
|
1302
1496
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
1303
|
-
export async function executeJobDetached(config, hooks) {
|
|
1304
|
-
|
|
1305
|
-
if (eligibility) {
|
|
1306
|
-
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
1307
|
-
throw new Error(eligibility.message);
|
|
1308
|
-
}
|
|
1309
|
-
return withRoutineLaunchClaim(config, () => executeJobDetachedClaimed(config, hooks));
|
|
1497
|
+
export async function executeJobDetached(config, hooks, trigger = { kind: 'manual' }) {
|
|
1498
|
+
return runWithAttempt(config, trigger, (attempt) => executeJobDetachedClaimed(config, attempt, hooks), (meta) => meta);
|
|
1310
1499
|
}
|
|
1311
|
-
async function executeJobDetachedClaimed(config, hooks) {
|
|
1312
|
-
// Hard-deprecated harness (e.g. gemini): fail loud before placement, version/
|
|
1313
|
-
// account resolution, or sandbox prep — local, host, AND cloud placement all
|
|
1314
|
-
// share this one gate (a hard-deprecated agent has no `cloudProvider` entry,
|
|
1315
|
-
// so cloud placement would otherwise silently fall back to the default
|
|
1316
|
-
// provider instead of refusing). See hardDeprecationRunFailure.
|
|
1317
|
-
if (!config.workflow && config.agent && ROUTINE_AGENT_IDS.includes(config.agent) && isAgentHardDeprecated(config.agent)) {
|
|
1318
|
-
return hardDeprecationRunFailure(config, config.agent);
|
|
1319
|
-
}
|
|
1500
|
+
async function executeJobDetachedClaimed(config, attempt, hooks) {
|
|
1320
1501
|
// Placement (hostStrategy / bare host:) — dispatch off-box and return; the
|
|
1321
1502
|
// monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
|
|
1322
1503
|
// Either way the in-process onFinish hook does not fire for off-box routines
|
|
@@ -1326,11 +1507,11 @@ async function executeJobDetachedClaimed(config, hooks) {
|
|
|
1326
1507
|
const { resolvePlacementTarget } = await import('./routines-placement.js');
|
|
1327
1508
|
const target = resolvePlacementTarget(config);
|
|
1328
1509
|
if (target.mode === 'host') {
|
|
1329
|
-
const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true });
|
|
1510
|
+
const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true }, attempt);
|
|
1330
1511
|
return meta;
|
|
1331
1512
|
}
|
|
1332
1513
|
if (target.mode === 'cloud') {
|
|
1333
|
-
const { meta } = await executeJobOnCloud(config, { detached: true });
|
|
1514
|
+
const { meta } = await executeJobOnCloud(config, { detached: true }, attempt);
|
|
1334
1515
|
return meta;
|
|
1335
1516
|
}
|
|
1336
1517
|
}
|
|
@@ -1338,7 +1519,7 @@ async function executeJobDetachedClaimed(config, hooks) {
|
|
|
1338
1519
|
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
1339
1520
|
// list/runs, and overdue tracking keep working.
|
|
1340
1521
|
if (config.command) {
|
|
1341
|
-
return executeCommandJobDetached(config, hooks);
|
|
1522
|
+
return executeCommandJobDetached(config, attempt, hooks);
|
|
1342
1523
|
}
|
|
1343
1524
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
1344
1525
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
@@ -1366,7 +1547,7 @@ async function executeJobDetachedClaimed(config, hooks) {
|
|
|
1366
1547
|
// `config.sandbox` (see the resume branch in buildJobCommand).
|
|
1367
1548
|
const useSandbox = config.sandbox !== false && !config.resume;
|
|
1368
1549
|
const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
|
|
1369
|
-
const runId =
|
|
1550
|
+
const runId = attempt.runId;
|
|
1370
1551
|
const runDir = getRunDir(config.name, runId);
|
|
1371
1552
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1372
1553
|
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
@@ -1388,6 +1569,7 @@ async function executeJobDetachedClaimed(config, hooks) {
|
|
|
1388
1569
|
jobName: config.name,
|
|
1389
1570
|
runId,
|
|
1390
1571
|
...runProvenance(config),
|
|
1572
|
+
...attempt.stamp,
|
|
1391
1573
|
agent: effectiveAgent,
|
|
1392
1574
|
version,
|
|
1393
1575
|
...(config.workflow ? { workflow: config.workflow } : {}),
|
|
@@ -1500,13 +1682,13 @@ async function executeJobDetachedClaimed(config, hooks) {
|
|
|
1500
1682
|
* un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
|
|
1501
1683
|
* `monitorRunningJobs` reaps the record on the next tick.
|
|
1502
1684
|
*/
|
|
1503
|
-
function executeCommandJobDetached(config, hooks) {
|
|
1685
|
+
function executeCommandJobDetached(config, attempt, hooks) {
|
|
1504
1686
|
const timer = createTimer('agent.run', {
|
|
1505
1687
|
jobName: config.name,
|
|
1506
1688
|
mode: config.mode,
|
|
1507
1689
|
schedule: config.schedule,
|
|
1508
1690
|
});
|
|
1509
|
-
const runId =
|
|
1691
|
+
const runId = attempt.runId;
|
|
1510
1692
|
const runDir = getRunDir(config.name, runId);
|
|
1511
1693
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1512
1694
|
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
@@ -1528,6 +1710,7 @@ function executeCommandJobDetached(config, hooks) {
|
|
|
1528
1710
|
jobName: config.name,
|
|
1529
1711
|
runId,
|
|
1530
1712
|
...runProvenance(config),
|
|
1713
|
+
...attempt.stamp,
|
|
1531
1714
|
command: config.command,
|
|
1532
1715
|
pid: null,
|
|
1533
1716
|
spawnedAt: Date.now(),
|
|
@@ -1781,8 +1964,18 @@ export function monitorRunningJobs() {
|
|
|
1781
1964
|
.filter((e) => e.isDirectory());
|
|
1782
1965
|
for (const jobDir of jobDirs) {
|
|
1783
1966
|
const jobRunsPath = path.join(runsDir, jobDir.name);
|
|
1784
|
-
|
|
1785
|
-
|
|
1967
|
+
let runDirs;
|
|
1968
|
+
try {
|
|
1969
|
+
runDirs = fs.readdirSync(jobRunsPath, { withFileTypes: true })
|
|
1970
|
+
.filter((e) => e.isDirectory());
|
|
1971
|
+
}
|
|
1972
|
+
catch (err) {
|
|
1973
|
+
// A retention/cleanup pass may remove a job directory after the root
|
|
1974
|
+
// snapshot. The next monitor sweep observes the remaining state.
|
|
1975
|
+
if (err.code === 'ENOENT')
|
|
1976
|
+
continue;
|
|
1977
|
+
throw err;
|
|
1978
|
+
}
|
|
1786
1979
|
for (const runDirEntry of runDirs) {
|
|
1787
1980
|
const metaPath = path.join(jobRunsPath, runDirEntry.name, 'meta.json');
|
|
1788
1981
|
if (!fs.existsSync(metaPath))
|
package/dist/lib/scheduler.d.ts
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
* on startup and reloads them on SIGHUP.
|
|
7
7
|
*/
|
|
8
8
|
import type { JobConfig } from './routines.js';
|
|
9
|
+
/** How a fire was triggered, carrying the scheduler's intended UTC slot time. */
|
|
10
|
+
export interface TriggerContext {
|
|
11
|
+
/** The cron slot this callback fires for (croner `currentRun()`), for the
|
|
12
|
+
* single-fire claim keyed on (routine, scheduledFor). */
|
|
13
|
+
scheduledFor?: Date;
|
|
14
|
+
}
|
|
9
15
|
/** In-memory cron scheduler that triggers a callback when jobs fire. */
|
|
10
16
|
export declare class JobScheduler {
|
|
11
17
|
private jobs;
|
|
12
18
|
private onTrigger;
|
|
13
|
-
constructor(onTrigger: (config: JobConfig) => Promise<void>);
|
|
19
|
+
constructor(onTrigger: (config: JobConfig, ctx?: TriggerContext) => Promise<void>);
|
|
14
20
|
loadAll(): void;
|
|
15
21
|
schedule(config: JobConfig): void;
|
|
16
22
|
unschedule(name: string): void;
|
package/dist/lib/scheduler.js
CHANGED
|
@@ -59,7 +59,7 @@ export class JobScheduler {
|
|
|
59
59
|
const cronOptions = { catch: true };
|
|
60
60
|
if (config.timezone)
|
|
61
61
|
cronOptions.timezone = config.timezone;
|
|
62
|
-
const cron = new Cron(config.schedule, cronOptions, async () => {
|
|
62
|
+
const cron = new Cron(config.schedule, cronOptions, async (self) => {
|
|
63
63
|
// endAt: once the configured end time has passed, auto-disable and stop
|
|
64
64
|
// firing. We persist enabled=false to disk so the next daemon reload
|
|
65
65
|
// doesn't re-schedule, and unschedule in-memory so this cron stops.
|
|
@@ -75,7 +75,10 @@ export class JobScheduler {
|
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
77
|
try {
|
|
78
|
-
|
|
78
|
+
// croner hands the callback its own Cron instance; `currentRun()` is the
|
|
79
|
+
// UTC time THIS invocation was scheduled for — the single-fire slot key.
|
|
80
|
+
// A duplicate delivery for the same slot resolves to one run downstream.
|
|
81
|
+
await this.onTrigger(config, { scheduledFor: self.currentRun() ?? undefined });
|
|
79
82
|
}
|
|
80
83
|
catch (err) {
|
|
81
84
|
console.error(`Job '${config.name}' failed:`, err.message);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// hook-runtime check — detects and bounded-repairs agents-managed generated
|
|
2
|
+
// hook shims (~/.agents/.cache/shims/hooks/<name>.sh). A native hook command can
|
|
3
|
+
// look wired while its generated wrapper is missing, non-executable, non-file,
|
|
4
|
+
// or broken — silent breakage. Repair runs at most once per unique path per
|
|
5
|
+
// pass (no retry, no sync recursion); unresolved findings stay needsAttention.
|
|
6
|
+
import { resultOf } from '../types.js';
|
|
7
|
+
import { repairManagedHookRuntimeArtifacts } from '../../hooks.js';
|
|
8
|
+
export const hookRuntimeCheck = {
|
|
9
|
+
id: 'hook-runtime',
|
|
10
|
+
title: 'Generated hook runtime shims',
|
|
11
|
+
cadence: 'frequent',
|
|
12
|
+
async run(ctx) {
|
|
13
|
+
const report = repairManagedHookRuntimeArtifacts({ dryRun: ctx.dryRun });
|
|
14
|
+
return resultOf(report.fixed, report.needsAttention);
|
|
15
|
+
},
|
|
16
|
+
};
|