@phnx-labs/agents-cli 1.22.30 → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/focus.d.ts +4 -1
  8. package/dist/commands/focus.js +19 -4
  9. package/dist/commands/routines.js +164 -36
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +59 -93
  12. package/dist/commands/{sessions-favorite.d.ts → sessions-bookmark.d.ts} +7 -7
  13. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +38 -38
  14. package/dist/commands/sessions-browser.d.ts +10 -8
  15. package/dist/commands/sessions-browser.js +61 -32
  16. package/dist/commands/sessions-stats.js +1 -1
  17. package/dist/commands/sessions.d.ts +11 -9
  18. package/dist/commands/sessions.js +114 -65
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +148 -0
  21. package/dist/index.js +4 -2
  22. package/dist/lib/catchup.js +4 -1
  23. package/dist/lib/daemon.d.ts +17 -0
  24. package/dist/lib/daemon.js +69 -3
  25. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  26. package/dist/lib/devices/doctor-findings.js +53 -2
  27. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  28. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  29. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  30. package/dist/lib/devices/fleet-divergence.js +6 -0
  31. package/dist/lib/devices/fleet-inventory.js +16 -2
  32. package/dist/lib/drift.d.ts +6 -1
  33. package/dist/lib/drift.js +9 -0
  34. package/dist/lib/hooks/cache.js +20 -1
  35. package/dist/lib/hooks.d.ts +91 -1
  36. package/dist/lib/hooks.js +289 -3
  37. package/dist/lib/hosts/passthrough.js +3 -0
  38. package/dist/lib/installations/index.d.ts +14 -0
  39. package/dist/lib/installations/index.js +14 -0
  40. package/dist/lib/installations/resolve.d.ts +43 -0
  41. package/dist/lib/installations/resolve.js +93 -0
  42. package/dist/lib/installations/store.d.ts +56 -0
  43. package/dist/lib/installations/store.js +196 -0
  44. package/dist/lib/installations/strategies.d.ts +73 -0
  45. package/dist/lib/installations/strategies.js +293 -0
  46. package/dist/lib/installations/types.d.ts +78 -0
  47. package/dist/lib/installations/types.js +8 -0
  48. package/dist/lib/installations/update.d.ts +40 -0
  49. package/dist/lib/installations/update.js +131 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/migrate.d.ts +27 -0
  53. package/dist/lib/migrate.js +118 -2
  54. package/dist/lib/picker.d.ts +6 -3
  55. package/dist/lib/picker.js +7 -2
  56. package/dist/lib/routine-context.d.ts +144 -0
  57. package/dist/lib/routine-context.js +268 -0
  58. package/dist/lib/routine-readiness.d.ts +47 -0
  59. package/dist/lib/routine-readiness.js +239 -0
  60. package/dist/lib/routines.d.ts +97 -1
  61. package/dist/lib/routines.js +107 -1
  62. package/dist/lib/runner.d.ts +18 -4
  63. package/dist/lib/runner.js +291 -98
  64. package/dist/lib/scheduler.d.ts +7 -1
  65. package/dist/lib/scheduler.js +5 -2
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/agent.d.ts +0 -1
  69. package/dist/lib/secrets/agent.js +0 -4
  70. package/dist/lib/secrets/session-store.d.ts +0 -4
  71. package/dist/lib/secrets/session-store.js +0 -5
  72. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  73. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  74. package/dist/lib/self-heal/registry.js +5 -2
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +14 -14
  77. package/dist/lib/session/{favorites.js → bookmarks.js} +22 -22
  78. package/dist/lib/session/state.js +4 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/versions.d.ts +24 -0
  82. package/dist/lib/versions.js +49 -16
  83. package/package.json +2 -2
@@ -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
- async function withRoutineLaunchClaim(config, launch) {
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
- /** Stable working directory for routine children, independent of the daemon's launch cwd. */
135
- export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
136
- if (!config.repo)
137
- return os.homedir();
138
- const [owner, repo] = config.repo.split('/');
139
- if (configuredRoot) {
140
- const root = path.resolve(expandLocalHome(configuredRoot));
141
- if (path.basename(root) === owner)
142
- return path.join(root, repo);
143
- }
144
- return path.join(os.homedir(), 'src', 'github.com', owner, repo);
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
- * `routines add`/`routines edit` refuse to create a routine pinned to a
777
- * hard-deprecated harness (commands/routines.ts), but that check runs only at
778
- * CLI-write time. A routine YAML written before the harness was deprecated, or
779
- * edited directly on disk / synced from another device, has no equivalent gate
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
- export async function executeJob(config, deps) {
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 = generateRunId();
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 = generateRunId();
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 = generateRunId();
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: config.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 = generateRunId();
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
- const eligibility = checkJobDeviceEligibility(config);
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 = generateRunId();
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 = generateRunId();
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
- const runDirs = fs.readdirSync(jobRunsPath, { withFileTypes: true })
1785
- .filter((e) => e.isDirectory());
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))
@@ -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;
@@ -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
- await this.onTrigger(config);
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);
@@ -463,7 +463,6 @@ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Reco
463
463
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
464
464
  * wiped, or 0 when no broker is running. */
465
465
  export declare function agentLock(name?: string): Promise<number>;
466
- export declare function agentRevoke(leaseId: string): Promise<number>;
467
466
  /** List currently-unlocked bundles, or [] when no broker is running. The
468
467
  * internal `secrets list` metadata-cache entry is filtered out here as well as
469
468
  * server-side: during a rollout a NEW client can talk to an OLD broker that
@@ -1310,10 +1310,6 @@ export async function agentLock(name) {
1310
1310
  const r = await request({ cmd: 'lock', name });
1311
1311
  return r?.ok === true && r.cmd === 'lock' ? r.wiped : 0;
1312
1312
  }
1313
- export async function agentRevoke(leaseId) {
1314
- const r = await request({ cmd: 'revoke', leaseId });
1315
- return r?.ok === true && r.cmd === 'revoke' ? r.wiped : 0;
1316
- }
1317
1313
  /** List currently-unlocked bundles, or [] when no broker is running. The
1318
1314
  * internal `secrets list` metadata-cache entry is filtered out here as well as
1319
1315
  * server-side: during a rollout a NEW client can talk to an OLD broker that